Skip to content

Commit 01b1714

Browse files
authored
JsonPointer optimizations (#117)
Rework JsonPointer to reduce time spent on creating a new one from the existing one. Resolves #116
1 parent 05f589b commit 01b1714

File tree

7 files changed

+173
-91
lines changed

7 files changed

+173
-91
lines changed

api/json-schema-validator.api

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
public abstract class io/github/optimumcode/json/pointer/JsonPointer {
22
public static final field Companion Lio/github/optimumcode/json/pointer/JsonPointer$Companion;
33
public static final field ROOT Lio/github/optimumcode/json/pointer/JsonPointer;
4-
public synthetic fun <init> (Ljava/lang/String;ILio/github/optimumcode/json/pointer/JsonPointer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
5-
public synthetic fun <init> (Ljava/lang/String;ILio/github/optimumcode/json/pointer/JsonPointer;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
4+
public synthetic fun <init> (Lio/github/optimumcode/json/pointer/JsonPointer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
5+
public synthetic fun <init> (Lio/github/optimumcode/json/pointer/JsonPointer;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
66
public final fun atIndex (I)Lio/github/optimumcode/json/pointer/JsonPointer;
77
public final fun atProperty (Ljava/lang/String;)Lio/github/optimumcode/json/pointer/JsonPointer;
88
public static final fun compile (Ljava/lang/String;)Lio/github/optimumcode/json/pointer/JsonPointer;

changelog_config.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
{
22
"categories": [
3+
{
4+
"title": "## ⚠ Breaking changes",
5+
"labels": ["API breaking", "ABI breaking"]
6+
},
37
{
48
"title": "## 🚀 Features",
59
"labels": ["enhancement"],

src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt

Lines changed: 108 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@ public fun JsonPointer(path: String): JsonPointer = JsonPointer.compile(path)
1414
* [RFC6901](https://datatracker.ietf.org/doc/html/rfc6901).
1515
*/
1616
public sealed class JsonPointer(
17-
private val fullPath: String,
18-
private val pathOffset: Int,
19-
internal val next: JsonPointer? = null,
17+
internal open val next: JsonPointer? = null,
2018
) {
19+
private var asString: String? = null
20+
private var hash: Int = 0
21+
2122
/**
2223
* Creates a new [JsonPointer] that points to an [index] in the array.
2324
*
@@ -26,15 +27,10 @@ public sealed class JsonPointer(
2627
* val pointer = JsonPointer("/test").atIndex(0) // "/test/0"
2728
* ```
2829
*/
29-
public fun atIndex(index: Int): JsonPointer =
30-
JsonPointer(
31-
buildString {
32-
val pointer = this@JsonPointer.toString()
33-
append(pointer)
34-
append(SEPARATOR)
35-
append(index)
36-
},
37-
)
30+
public fun atIndex(index: Int): JsonPointer {
31+
require(index >= 0) { "negative index: $index" }
32+
return atProperty(index.toString())
33+
}
3834

3935
/**
4036
* Creates a new [JsonPointer] that points to a [property] passed as a parameter.
@@ -44,28 +40,58 @@ public sealed class JsonPointer(
4440
* val pointer = JsonPointer.ROOT.atProperty("prop1").atProperty("prop2") // "/prop1/prop2"
4541
* ```
4642
*/
47-
public fun atProperty(property: String): JsonPointer =
48-
JsonPointer(
49-
buildString {
50-
val pointer = this@JsonPointer.toString()
51-
append(pointer)
43+
public fun atProperty(property: String): JsonPointer = insertLast(SegmentPointer(property))
44+
45+
override fun toString(): String {
46+
val str = asString
47+
if (str != null) {
48+
return str
49+
}
50+
if (this !is SegmentPointer) {
51+
return ""
52+
}
53+
return buildString {
54+
var node: JsonPointer = this@JsonPointer
55+
while (node is SegmentPointer) {
5256
append(SEPARATOR)
53-
for (ch in property) {
57+
append(escapeJsonPointer(node.propertyName))
58+
node = node.next
59+
}
60+
}.also {
61+
asString = it
62+
}
63+
}
64+
65+
internal fun insertLast(last: SegmentPointer): JsonPointer {
66+
if (this !is SegmentPointer) {
67+
return last
68+
}
69+
var parent: PointerParent? = null
70+
var node: JsonPointer = this
71+
while (node is SegmentPointer) {
72+
parent =
73+
PointerParent(
74+
parent,
75+
node.propertyName,
76+
)
77+
node = node.next
78+
}
79+
return buildPath(last, parent)
80+
}
81+
82+
private fun escapeJsonPointer(propertyName: String): String {
83+
if (propertyName.contains(SEPARATOR) || propertyName.contains(QUOTATION)) {
84+
return buildString(capacity = propertyName.length + 1) {
85+
for (ch in propertyName) {
5486
when (ch) {
55-
QUOTATION -> append(QUOTATION).append(QUOTATION_ESCAPE)
5687
SEPARATOR -> append(QUOTATION).append(SEPARATOR_ESCAPE)
88+
QUOTATION -> append(QUOTATION).append(QUOTATION_ESCAPE)
5789
else -> append(ch)
5890
}
5991
}
60-
},
61-
)
62-
63-
override fun toString(): String {
64-
return if (pathOffset <= 0) {
65-
fullPath
66-
} else {
67-
fullPath.substring(pathOffset)
92+
}
6893
}
94+
return propertyName
6995
}
7096

7197
override fun equals(other: Any?): Boolean {
@@ -74,13 +100,34 @@ public sealed class JsonPointer(
74100

75101
other as JsonPointer
76102

77-
if (fullPath != other.fullPath) return false
78-
return pathOffset == other.pathOffset
103+
var node = this
104+
var otherNode = other
105+
while (node is SegmentPointer && otherNode is SegmentPointer) {
106+
if (node.propertyName != otherNode.propertyName) {
107+
return false
108+
}
109+
node = node.next
110+
otherNode = otherNode.next
111+
}
112+
return node is EmptyPointer && otherNode is EmptyPointer
79113
}
80114

81115
override fun hashCode(): Int {
82-
var result = fullPath.hashCode()
83-
result = 31 * result + pathOffset
116+
if (hash != 0) {
117+
return hash
118+
}
119+
var result = 31
120+
var node = this
121+
while (node is SegmentPointer) {
122+
result = 31 * result + node.propertyName.hashCode()
123+
node = node.next
124+
}
125+
if (result == 0) {
126+
// just in case if for some reason the resulting has is zero
127+
// this way we won't recalculate it again
128+
result = 31
129+
}
130+
hash = result
84131
return result
85132
}
86133

@@ -118,42 +165,32 @@ public sealed class JsonPointer(
118165
}
119166
}
120167

121-
@JvmStatic
122-
private fun parseExpression(expr: String): JsonPointer {
123-
class PointerParent(
124-
val parent: PointerParent?,
125-
val startOffset: Int,
126-
val segment: String,
127-
)
168+
private class PointerParent(
169+
val parent: PointerParent?,
170+
val segment: String,
171+
)
128172

129-
fun buildPath(
130-
start: Int,
131-
lastSegment: String,
132-
parent: PointerParent?,
133-
): JsonPointer {
134-
var curr =
135-
SegmentPointer(
136-
expr,
137-
start,
138-
lastSegment,
139-
EmptyPointer,
140-
)
141-
var parentValue = parent
142-
while (parentValue != null) {
143-
curr =
144-
parentValue.run {
145-
SegmentPointer(
146-
expr,
147-
startOffset,
148-
segment,
149-
curr,
150-
)
151-
}
152-
parentValue = parentValue.parent
153-
}
154-
return curr
173+
private fun buildPath(
174+
lastSegment: SegmentPointer,
175+
parent: PointerParent?,
176+
): JsonPointer {
177+
var curr = lastSegment
178+
var parentValue = parent
179+
while (parentValue != null) {
180+
curr =
181+
parentValue.run {
182+
SegmentPointer(
183+
segment,
184+
curr,
185+
)
186+
}
187+
parentValue = parentValue.parent
155188
}
189+
return curr
190+
}
156191

192+
@JvmStatic
193+
private fun parseExpression(expr: String): JsonPointer {
157194
var parent: PointerParent? = null
158195

159196
var offset = 1 // skip contextual slash
@@ -162,7 +199,7 @@ public sealed class JsonPointer(
162199
while (offset < end) {
163200
val currentChar = expr[offset]
164201
if (currentChar == SEPARATOR) {
165-
parent = PointerParent(parent, start, expr.substring(start + 1, offset))
202+
parent = PointerParent(parent, expr.substring(start + 1, offset))
166203
start = offset
167204
offset++
168205
continue
@@ -173,15 +210,15 @@ public sealed class JsonPointer(
173210
offset = builder.appendEscapedSegment(expr, start + 1, offset)
174211
val segment = builder.toString()
175212
if (offset < 0) {
176-
return buildPath(start, segment, parent)
213+
return buildPath(SegmentPointer(segment), parent)
177214
}
178-
parent = PointerParent(parent, start, segment)
215+
parent = PointerParent(parent, segment)
179216
start = offset
180217
offset++
181218
continue
182219
}
183220
}
184-
return buildPath(start, expr.substring(start + 1), parent)
221+
return buildPath(SegmentPointer(expr.substring(start + 1)), parent)
185222
}
186223
}
187224
}
@@ -229,14 +266,12 @@ private fun StringBuilder.appendEscaped(ch: Char) {
229266
append(result)
230267
}
231268

232-
internal object EmptyPointer : JsonPointer(fullPath = "", pathOffset = 0)
269+
internal object EmptyPointer : JsonPointer()
233270

234271
internal class SegmentPointer(
235-
fullPath: String,
236-
pathOffset: Int,
237272
segment: String,
238-
next: JsonPointer? = null,
239-
) : JsonPointer(fullPath, pathOffset, next) {
273+
override val next: JsonPointer = EmptyPointer,
274+
) : JsonPointer(next) {
240275
val propertyName: String = segment
241276
val index: Int = parseIndex(segment)
242277

src/commonMain/kotlin/io/github/optimumcode/json/pointer/extensions.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,7 @@ public operator fun JsonPointer.plus(otherPointer: JsonPointer): JsonPointer {
5555
if (otherPointer is EmptyPointer) {
5656
return this
5757
}
58-
return JsonPointer(
59-
buildString {
60-
append(this@plus.toString())
61-
append(otherPointer.toString())
62-
},
63-
)
58+
return this.insertLast(otherPointer as SegmentPointer)
6459
}
6560

6661
/**
@@ -165,7 +160,7 @@ public tailrec fun JsonElement.at(pointer: JsonPointer): JsonElement? {
165160
is EmptyPointer -> this
166161
is SegmentPointer -> {
167162
val next = atPointer(pointer)
168-
next?.at(pointer.next ?: error("pointer $pointer does not has next segment and is not EmptyPointer"))
163+
next?.at(pointer.next)
169164
}
170165
}
171166
}

src/commonMain/kotlin/io/github/optimumcode/json/schema/internal/ReferenceValidator.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ internal object ReferenceValidator {
4747
schemaPath to refId
4848
}
4949

50-
val circledReferences = hashSetOf<CircledReference>()
50+
val circledReferences = linkedSetOf<CircledReference>()
5151

5252
val refsByBaseId: Map<Uri, Set<JsonPointer>> =
5353
referencesWithPath

src/commonTest/kotlin/io/github/optimumcode/json/pointer/JsonPointerTest.kt

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,10 @@ class JsonPointerTest : FunSpec() {
5050
val pointer = JsonPointer("/first/second")
5151
assertSoftly {
5252
pointer.assertSegment(property = "first")
53-
pointer.next shouldNotBe null
54-
pointer.next!!.assertSegment(property = "second")
55-
pointer.next.next shouldBe EmptyPointer
53+
val next = pointer.next
54+
next shouldNotBe null
55+
next!!.assertSegment(property = "second")
56+
next.next shouldBe EmptyPointer
5657
}
5758
}
5859

@@ -111,6 +112,39 @@ class JsonPointerTest : FunSpec() {
111112
pointer.toString() shouldBe "/test1//test2"
112113
}
113114
}
115+
116+
listOf(
117+
JsonPointer.ROOT to JsonPointer("/test"),
118+
JsonPointer("/test") to JsonPointer.ROOT,
119+
JsonPointer("/test1") to JsonPointer("/test2"),
120+
JsonPointer("/test/another") to JsonPointer("/test"),
121+
JsonPointer("/test") to JsonPointer("/test/another"),
122+
).forEach { (a, b) ->
123+
test("'$a' not equal '$b'") {
124+
a shouldNotBe b
125+
}
126+
}
127+
128+
test("negative index is not allowed") {
129+
shouldThrow<IllegalArgumentException> {
130+
JsonPointer.ROOT.atIndex(-1)
131+
}.message shouldBe "negative index: -1"
132+
}
133+
134+
test("~2 is not escaping") {
135+
JsonPointer("/~2test")
136+
.assertSegment("~2test")
137+
}
138+
139+
test("~ in the end is not escaping") {
140+
JsonPointer("/~")
141+
.assertSegment("~")
142+
}
143+
144+
test("property that starts with number does not result in index") {
145+
JsonPointer("/1test")
146+
.assertSegment("1test", index = -1)
147+
}
114148
}
115149

116150
private fun JsonPointer.assertSegment(

0 commit comments

Comments
 (0)