Skip to content

Commit 60ec6f2

Browse files
authored
Merge pull request #40 from orangain/optional-marker
Add support for optional marker
2 parents 572fcbb + 25c4abe commit 60ec6f2

File tree

7 files changed

+157
-6
lines changed

7 files changed

+157
-6
lines changed

.editorconfig

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
root = true
2+
3+
[*]
4+
end_of_line = lf
5+
insert_final_newline = true

README.md

+4
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,10 @@ There are several markers as followings:
111111
| `#regex STR` | Expects actual (string) value to match the regular-expression 'STR' (see examples below) |
112112
| `#[NUM] EXPR` | Advanced array marker. When NUM is provided, array must has length just NUM. When EXPR is provided, array's element must match the pattern 'EXPR' (see examples below) |
113113

114+
### Optional marker
115+
116+
You can use double hash `##` to mark a field as optional. For example, `##string` means that the field can be a string, null or not present.
117+
114118
### Examples
115119

116120
| Pattern | `{}` | `{ "a": null }` | `{ "a": "abc" }` |

src/main/java/io/github/orangain/jsonmatch/parser/JsonMatchPatternParser.java

+30-5
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public static JsonPatternNode parse(@NotNull JsonNode jsonNode) {
2929
} else if (jsonNode.isArray()) {
3030
return parseArray(jsonNode);
3131
} else {
32-
return parseValue(jsonNode);
32+
return parseSimpleValue(jsonNode);
3333
}
3434
}
3535

@@ -57,7 +57,7 @@ private static JsonPatternNode parseArray(@NotNull JsonNode jsonNode) {
5757
}
5858

5959
@NotNull
60-
private static JsonPatternNode parseValue(@NotNull JsonNode jsonNode) {
60+
private static JsonPatternNode parseSimpleValue(@NotNull JsonNode jsonNode) {
6161
if (jsonNode.isTextual()) {
6262
JsonPatternNode parsed = parseMarkerOrNull(jsonNode.textValue());
6363
if (parsed != null) {
@@ -70,21 +70,42 @@ private static JsonPatternNode parseValue(@NotNull JsonNode jsonNode) {
7070

7171
@Nullable
7272
private static JsonPatternNode parseMarkerOrNull(@NotNull String value) {
73+
if (!value.startsWith("#")) {
74+
return null;
75+
}
76+
7377
switch (value) {
7478
case "#ignore":
7579
return IgnoreMarkerPatternNode.getInstance();
76-
case "#null":
77-
return TypeMarkerPatternNode.NULL;
7880
case "#notnull":
7981
return NotNullMarkerPatternNode.getInstance();
8082
case "#present":
8183
return PresentMarkerPatternNode.getInstance();
8284
case "#notpresent":
8385
return NotPresentMarkerPatternNode.getInstance();
86+
}
87+
88+
if (value.startsWith("##")) {
89+
var innerNode = parseValueMarkerOrNull(value.substring(1));
90+
if (innerNode != null) {
91+
return new OptionalPatternNode(JsonUtil.toJsonString(value), innerNode);
92+
} else {
93+
return null;
94+
}
95+
} else {
96+
return parseValueMarkerOrNull(value);
97+
}
98+
}
99+
100+
@Nullable
101+
private static JsonPatternNode parseValueMarkerOrNull(@NotNull String value) {
102+
switch (value) {
84103
case "#array":
85104
return new ArrayMarkerPatternNode(JsonUtil.toJsonString(value));
86105
case "#object":
87106
return new ObjectMarkerPatternNode(JsonUtil.toJsonString(value));
107+
case "#null":
108+
return TypeMarkerPatternNode.NULL;
88109
case "#boolean":
89110
return TypeMarkerPatternNode.BOOLEAN;
90111
case "#number":
@@ -103,6 +124,11 @@ private static JsonPatternNode parseMarkerOrNull(@NotNull String value) {
103124
return new RegexMarkerPatternNode(JsonUtil.toJsonString(value), value.substring("#regex".length()).trim());
104125
}
105126

127+
return parseArrayMarkerOrNull(value);
128+
}
129+
130+
@Nullable
131+
private static JsonPatternNode parseArrayMarkerOrNull(@NotNull String value) {
106132
Matcher arrayMatcher = ARRAY_PATTERN.matcher(value);
107133
if (arrayMatcher.matches()) {
108134
String length = arrayMatcher.group(1);
@@ -115,7 +141,6 @@ private static JsonPatternNode parseMarkerOrNull(@NotNull String value) {
115141
return new ArrayMarkerPatternNode(JsonUtil.toJsonString(value), Integer.parseInt(length), childPattern);
116142
}
117143
}
118-
119144
return null;
120145
}
121146
}

src/main/java/io/github/orangain/jsonmatch/pattern/JsonMatchErrorDetail.java

+9
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,13 @@ public String toString() {
3333
", expected: " + expected +
3434
", reason: " + reason;
3535
}
36+
37+
/**
38+
* Creates a new JSON match error detail with the expected JSON string.
39+
* @param expected The expected JSON string.
40+
* @return A new JSON match error detail.
41+
*/
42+
public @NotNull JsonMatchErrorDetail withExpected(String expected) {
43+
return new JsonMatchErrorDetail(path, actual, expected, reason);
44+
}
3645
}

src/main/java/io/github/orangain/jsonmatch/pattern/JsonPatternNode.java

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public abstract class JsonPatternNode {
1313
/**
1414
* The string representation of the expected JSON pattern.
1515
*/
16-
private final String expected;
16+
protected final String expected;
1717

1818
/**
1919
* Constructor of the JSON pattern node.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package io.github.orangain.jsonmatch.pattern;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
import io.github.orangain.jsonmatch.json.JsonPath;
5+
import org.jetbrains.annotations.NotNull;
6+
7+
import java.util.Optional;
8+
9+
/**
10+
* JSON pattern node that matches an optional JSON node. Optional means that the node can be missing or null, but if it
11+
* is present, it must match the inner node.
12+
*/
13+
public class OptionalPatternNode extends JsonPatternNode {
14+
private final JsonPatternNode innerNode;
15+
16+
/**
17+
* Constructor of the optional pattern node.
18+
*
19+
* @param expected The string representation of the expected JSON pattern.
20+
*/
21+
public OptionalPatternNode(@NotNull String expected, @NotNull JsonPatternNode innerNode) {
22+
super(expected);
23+
this.innerNode = innerNode;
24+
}
25+
26+
@NotNull
27+
@Override
28+
public Optional<JsonMatchErrorDetail> matches(@NotNull JsonPath path, @NotNull JsonNode actualNode) {
29+
if (actualNode.isMissingNode() || actualNode.isNull()) {
30+
return Optional.empty();
31+
}
32+
33+
return innerNode.matches(path, actualNode).map(detail -> detail.withExpected(expected));
34+
}
35+
36+
@Override
37+
protected boolean canBeMissing() {
38+
return true;
39+
}
40+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package io.github.orangain.jsonmatch.marker
2+
3+
import io.github.orangain.jsonmatch.JsonStringAssert
4+
import org.assertj.core.api.Assertions
5+
import org.junit.jupiter.api.Test
6+
import org.junit.jupiter.params.ParameterizedTest
7+
import org.junit.jupiter.params.provider.Arguments
8+
import org.junit.jupiter.params.provider.Arguments.arguments
9+
import org.junit.jupiter.params.provider.MethodSource
10+
import java.util.stream.Stream
11+
12+
class OptionalMarkerTest {
13+
@Test
14+
fun optionalPatternShouldMatchPresentValue() {
15+
// language=JSON
16+
JsonStringAssert.assertThat("""{ "a": "foo" }""")
17+
.jsonMatches("""{ "a": "##string" }""")
18+
}
19+
20+
@Test
21+
fun optionalPatternShouldFailWhenInnerPatternDoesNotMatch() {
22+
Assertions.assertThatThrownBy {
23+
// language=JSON
24+
JsonStringAssert.assertThat("""{ "a": 1 }""")
25+
.jsonMatches("""{ "a": "##string" }""")
26+
}.isInstanceOf(AssertionError::class.java)
27+
.hasMessageContaining("""path: $.a, actual: 1, expected: "##string", reason: not a string""")
28+
}
29+
30+
@ParameterizedTest
31+
@MethodSource("optionalMarkers")
32+
fun optionalPatternShouldMatchEmptyObject(marker: String) {
33+
require(!marker.contains("\"")) { "Please add implementation to escape double quotes." }
34+
val patternJson = """{ "a": "$marker" }"""
35+
// language=JSON
36+
JsonStringAssert.assertThat("""{}""")
37+
.jsonMatches(patternJson)
38+
}
39+
40+
@ParameterizedTest
41+
@MethodSource("optionalMarkers")
42+
fun optionalPatternShouldMatchNull(marker: String) {
43+
require(!marker.contains("\"")) { "Please add implementation to escape double quotes." }
44+
val patternJson = """{ "a": "$marker" }"""
45+
// language=JSON
46+
JsonStringAssert.assertThat("""{"a": null}""")
47+
.jsonMatches(patternJson)
48+
}
49+
50+
companion object {
51+
@JvmStatic
52+
fun optionalMarkers(): Stream<Arguments> {
53+
return Stream.of(
54+
arguments("##null"),
55+
arguments("##array"),
56+
arguments("##object"),
57+
arguments("##boolean"),
58+
arguments("##number"),
59+
arguments("##string"),
60+
arguments("##uuid"),
61+
arguments("##date"),
62+
arguments("##datetime"),
63+
arguments("##regex a+"),
64+
arguments("##[1]"),
65+
)
66+
}
67+
}
68+
}

0 commit comments

Comments
 (0)