Skip to content

Commit 7c9a871

Browse files
committed
feat(json): added a simple json element extractor
1 parent 1a27168 commit 7c9a871

File tree

3 files changed

+253
-0
lines changed

3 files changed

+253
-0
lines changed
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
package com.bugsnag.android.internal.json
2+
3+
/**
4+
* A simple path implementation similar to json path, but much simpler. The notation is strictly
5+
* dot (`'.'`) separated and does not support name escaping. Paths are parsed from strings and
6+
* can be efficiently evaluated any number of times once parsed, and are thread safe.
7+
*
8+
* Paths are in the form `"property.0.-1.*.value"` where `'*'` is a wildcard match, `0` is the
9+
* first item of an array (or a property named `"0"`) and `-1` is the last element in an array (or
10+
* a property named `"-1"`). Null values or non-existent values are skipped.
11+
*/
12+
internal class JsonCollectionPath private constructor(
13+
private val root: PathNode,
14+
private val path: String
15+
) {
16+
/**
17+
* Extract all of the selected values from the given JSON object stored in the given `Map`.
18+
*/
19+
fun extractFrom(json: Map<String, *>): List<Any> {
20+
val out = ArrayList<Any>()
21+
root.visit(json, out::add)
22+
return out
23+
}
24+
25+
override fun toString(): String {
26+
return path
27+
}
28+
29+
companion object {
30+
val IDENTITY_PATH = JsonCollectionPath(PathNode.TerminalNode, "")
31+
32+
fun fromString(path: String): JsonCollectionPath {
33+
if (path.isEmpty()) {
34+
return IDENTITY_PATH
35+
}
36+
37+
val segments = path.split('.')
38+
.reversed() // we build the path backwards
39+
40+
var node: PathNode = PathNode.TerminalNode
41+
segments.forEach { segment ->
42+
node = segment.toPathNode(node)
43+
}
44+
45+
return JsonCollectionPath(node, path)
46+
}
47+
48+
private fun String.toPathNode(next: PathNode): PathNode {
49+
if (this == "*") {
50+
return PathNode.Wildcard(next)
51+
}
52+
53+
val index = this.toIntOrNull()
54+
if (index != null) {
55+
return if (index < 0) {
56+
PathNode.NegativeIndex(index, next)
57+
} else {
58+
PathNode.PositiveIndex(index, next)
59+
}
60+
}
61+
62+
return PathNode.Property(this, next)
63+
}
64+
}
65+
66+
private sealed class PathNode {
67+
abstract fun visit(element: Any, collector: (Any) -> Unit)
68+
69+
abstract class NonTerminalPathNode(protected val next: PathNode) : PathNode()
70+
71+
class Property(val name: String, next: PathNode) : NonTerminalPathNode(next) {
72+
override fun visit(element: Any, collector: (Any) -> Unit) {
73+
if (element is Map<*, *>) {
74+
element[name]?.let { next.visit(it, collector) }
75+
}
76+
}
77+
78+
override fun toString(): String = name
79+
}
80+
81+
class Wildcard(next: PathNode) : NonTerminalPathNode(next) {
82+
override fun visit(element: Any, collector: (Any) -> Unit) {
83+
if (element is Iterable<*>) {
84+
element.forEach { item ->
85+
item?.let { next.visit(it, collector) }
86+
}
87+
} else if (element is Map<*, *>) {
88+
element.values.forEach { item ->
89+
item?.let { next.visit(it, collector) }
90+
}
91+
}
92+
}
93+
}
94+
95+
abstract class IndexPathNode(
96+
protected val index: Int,
97+
next: PathNode
98+
) : NonTerminalPathNode(next) {
99+
protected abstract fun normalisedIndex(list: List<*>): Int
100+
101+
override fun visit(element: Any, collector: (Any) -> Unit) {
102+
if (element is List<*>) {
103+
val normalised = normalisedIndex(element)
104+
element.getOrNull(normalised)?.let { next.visit(it, collector) }
105+
} else if (element is Map<*, *>) {
106+
val value = element[index.toString()]
107+
value?.let { next.visit(it, collector) }
108+
}
109+
}
110+
}
111+
112+
class PositiveIndex(index: Int, next: PathNode) : IndexPathNode(index, next) {
113+
override fun normalisedIndex(list: List<*>): Int {
114+
return index
115+
}
116+
}
117+
118+
class NegativeIndex(index: Int, next: PathNode) : IndexPathNode(index, next) {
119+
override fun normalisedIndex(list: List<*>): Int {
120+
return list.size + index
121+
}
122+
}
123+
124+
object TerminalNode : PathNode() {
125+
override fun visit(element: Any, collector: (Any) -> Unit) {
126+
collector(element)
127+
}
128+
}
129+
}
130+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.bugsnag.android.internal.json
2+
3+
import com.bugsnag.android.internal.JsonCollectionParser
4+
import org.junit.Assert.assertEquals
5+
import org.junit.Assert.assertNotNull
6+
import org.junit.Test
7+
8+
internal class JsonCollectionPathTest {
9+
@Test
10+
fun extractLastArrayElement() {
11+
val extracted = extractPathFromResource(
12+
"metaData.structuralTests.arrayTests.nested.*.-1",
13+
"event_serialization_9.json"
14+
)
15+
16+
assertEquals(
17+
listOf(2L, listOf(4L, 5L), listOf(7L, listOf(8L, 9L))),
18+
extracted
19+
)
20+
}
21+
22+
@Test
23+
fun extractFirstArrayElement() {
24+
val extracted = extractPathFromResource(
25+
"metaData.structuralTests.arrayTests.nested.*.0",
26+
"event_serialization_9.json"
27+
)
28+
29+
assertEquals(
30+
listOf(1L, 3L, 6L),
31+
extracted
32+
)
33+
}
34+
35+
@Test
36+
fun nonExistentPropertyPath() {
37+
val extracted = extractPathFromResource(
38+
"metaData.structuralTests.arrayTests.noValueHere",
39+
"event_serialization_9.json"
40+
)
41+
42+
assertEquals(0, extracted.size)
43+
}
44+
45+
@Test
46+
fun nonExistentNumericPath() {
47+
val extracted = extractPathFromResource(
48+
"metaData.structuralTests.arrayTests.0",
49+
"event_serialization_9.json"
50+
)
51+
52+
assertEquals(0, extracted.size)
53+
}
54+
55+
@Test
56+
fun nonExistentNegativeNumericPath() {
57+
val extracted = extractPathFromResource(
58+
"metaData.structuralTests.arrayTests.-1",
59+
"event_serialization_9.json"
60+
)
61+
62+
assertEquals(0, extracted.size)
63+
}
64+
65+
@Test
66+
fun numericMapKeys() {
67+
val numberKeys = extractPathFromResource("metaData.numbers.0", "path_fixture.json")
68+
assertEquals(listOf("naught"), numberKeys)
69+
}
70+
71+
@Test
72+
fun wildcardArrayElements() {
73+
val numberKeys =
74+
extractPathFromResource("metaData.arrayOfObjects.*.name", "path_fixture.json")
75+
assertEquals(listOf("one", "two", "three"), numberKeys)
76+
}
77+
78+
@Test
79+
fun wildcardObjectProperties() {
80+
val numberKeys = extractPathFromResource("metaData.numbers.*", "path_fixture.json")
81+
assertEquals(listOf("naught", "one", "two", "three"), numberKeys)
82+
}
83+
84+
@Test
85+
fun toStringReturnsPath() {
86+
val path = "name.string.more words are here.0.-1.*.*.*.\uD83D\uDE00\uD83D\uDE03\uD83D\uDE04"
87+
val json = JsonCollectionPath.fromString(path)
88+
89+
assertEquals(path, json.toString())
90+
}
91+
92+
private fun extractPathFromResource(path: String, resource: String): List<Any> {
93+
val json = JsonCollectionParser(this::class.java.getResourceAsStream("/$resource")!!)
94+
.parse()
95+
96+
val collectionPath = JsonCollectionPath.fromString(path)
97+
assertNotNull("path failed to parse: '$path'", collectionPath)
98+
99+
@Suppress("UNCHECKED_CAST")
100+
return collectionPath.extractFrom(json as Map<String, *>)
101+
}
102+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"metaData": {
3+
"numbers": {
4+
"0": "naught",
5+
"1": "one",
6+
"2": "two",
7+
"3": "three"
8+
},
9+
"arrayOfObjects": [
10+
{
11+
"name": "one"
12+
},
13+
{
14+
"name": "two"
15+
},
16+
{
17+
"name": "three"
18+
}
19+
]
20+
}
21+
}

0 commit comments

Comments
 (0)