Skip to content

Commit bded5af

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

File tree

3 files changed

+247
-0
lines changed

3 files changed

+247
-0
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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+
fun fromString(path: String): JsonCollectionPath {
31+
val segments = path.split('.')
32+
.reversed() // we build the path backwards
33+
34+
var node: PathNode = PathNode.TerminalNode
35+
segments.forEach { segment ->
36+
node = segment.toPathNode(node)
37+
}
38+
39+
return JsonCollectionPath(node, path)
40+
}
41+
42+
private fun String.toPathNode(next: PathNode): PathNode {
43+
if (this == "*") {
44+
return PathNode.Wildcard(next)
45+
}
46+
47+
val index = this.toIntOrNull()
48+
if (index != null) {
49+
return if (index < 0) {
50+
PathNode.NegativeIndex(index, next)
51+
} else {
52+
PathNode.PositiveIndex(index, next)
53+
}
54+
}
55+
56+
return PathNode.Property(this, next)
57+
}
58+
}
59+
60+
private sealed class PathNode {
61+
abstract fun visit(element: Any, collector: (Any) -> Unit)
62+
63+
abstract class NonTerminalPathNode(protected val next: PathNode) : PathNode()
64+
65+
class Property(val name: String, next: PathNode) : NonTerminalPathNode(next) {
66+
override fun visit(element: Any, collector: (Any) -> Unit) {
67+
if (element is Map<*, *>) {
68+
element[name]?.let { next.visit(it, collector) }
69+
}
70+
}
71+
72+
override fun toString(): String = name
73+
}
74+
75+
class Wildcard(next: PathNode) : NonTerminalPathNode(next) {
76+
override fun visit(element: Any, collector: (Any) -> Unit) {
77+
if (element is Iterable<*>) {
78+
element.forEach { item ->
79+
item?.let { next.visit(it, collector) }
80+
}
81+
} else if (element is Map<*, *>) {
82+
element.values.forEach { item ->
83+
item?.let { next.visit(it, collector) }
84+
}
85+
}
86+
}
87+
}
88+
89+
abstract class IndexPathNode(
90+
protected val index: Int,
91+
next: PathNode
92+
) : NonTerminalPathNode(next) {
93+
protected abstract fun normalisedIndex(list: List<*>): Int
94+
95+
override fun visit(element: Any, collector: (Any) -> Unit) {
96+
if (element is List<*>) {
97+
val normalised = normalisedIndex(element)
98+
element.getOrNull(normalised)?.let { next.visit(it, collector) }
99+
} else if (element is Map<*, *>) {
100+
val value = element[index.toString()]
101+
value?.let { next.visit(it, collector) }
102+
}
103+
}
104+
}
105+
106+
class PositiveIndex(index: Int, next: PathNode) : IndexPathNode(index, next) {
107+
override fun normalisedIndex(list: List<*>): Int {
108+
return index
109+
}
110+
}
111+
112+
class NegativeIndex(index: Int, next: PathNode) : IndexPathNode(index, next) {
113+
override fun normalisedIndex(list: List<*>): Int {
114+
return list.size + index
115+
}
116+
}
117+
118+
object TerminalNode : PathNode() {
119+
override fun visit(element: Any, collector: (Any) -> Unit) {
120+
collector(element)
121+
}
122+
}
123+
}
124+
}
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)