Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JsonPointer optimizations #117

Merged
merged 3 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions api/json-schema-validator.api
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
public abstract class io/github/optimumcode/json/pointer/JsonPointer {
public static final field Companion Lio/github/optimumcode/json/pointer/JsonPointer$Companion;
public static final field ROOT Lio/github/optimumcode/json/pointer/JsonPointer;
public synthetic fun <init> (Ljava/lang/String;ILio/github/optimumcode/json/pointer/JsonPointer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Ljava/lang/String;ILio/github/optimumcode/json/pointer/JsonPointer;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Lio/github/optimumcode/json/pointer/JsonPointer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public synthetic fun <init> (Lio/github/optimumcode/json/pointer/JsonPointer;Lkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun atIndex (I)Lio/github/optimumcode/json/pointer/JsonPointer;
public final fun atProperty (Ljava/lang/String;)Lio/github/optimumcode/json/pointer/JsonPointer;
public static final fun compile (Ljava/lang/String;)Lio/github/optimumcode/json/pointer/JsonPointer;
Expand Down
4 changes: 4 additions & 0 deletions changelog_config.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"categories": [
{
"title": "## ⚠ Breaking changes",
"labels": ["API breaking", "ABI breaking"]
},
{
"title": "## 🚀 Features",
"labels": ["enhancement"],
Expand Down
181 changes: 108 additions & 73 deletions src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
* [RFC6901](https://datatracker.ietf.org/doc/html/rfc6901).
*/
public sealed class JsonPointer(
private val fullPath: String,
private val pathOffset: Int,
internal val next: JsonPointer? = null,
internal open val next: JsonPointer? = null,
) {
private var asString: String? = null
private var hash: Int = 0

/**
* Creates a new [JsonPointer] that points to an [index] in the array.
*
Expand All @@ -26,15 +27,10 @@
* val pointer = JsonPointer("/test").atIndex(0) // "/test/0"
* ```
*/
public fun atIndex(index: Int): JsonPointer =
JsonPointer(
buildString {
val pointer = this@JsonPointer.toString()
append(pointer)
append(SEPARATOR)
append(index)
},
)
public fun atIndex(index: Int): JsonPointer {
require(index >= 0) { "negative index: $index" }
return atProperty(index.toString())
}

/**
* Creates a new [JsonPointer] that points to a [property] passed as a parameter.
Expand All @@ -44,28 +40,58 @@
* val pointer = JsonPointer.ROOT.atProperty("prop1").atProperty("prop2") // "/prop1/prop2"
* ```
*/
public fun atProperty(property: String): JsonPointer =
JsonPointer(
buildString {
val pointer = this@JsonPointer.toString()
append(pointer)
public fun atProperty(property: String): JsonPointer = insertLast(SegmentPointer(property))

override fun toString(): String {
val str = asString
if (str != null) {
return str
}
if (this !is SegmentPointer) {
return ""
}
return buildString {
var node: JsonPointer = this@JsonPointer
while (node is SegmentPointer) {
append(SEPARATOR)
for (ch in property) {
append(escapeJsonPointer(node.propertyName))
node = node.next
}
}.also {
asString = it
}
}

internal fun insertLast(last: SegmentPointer): JsonPointer {
if (this !is SegmentPointer) {
return last
}
var parent: PointerParent? = null
var node: JsonPointer = this
while (node is SegmentPointer) {
parent =
PointerParent(
parent,
node.propertyName,
)
node = node.next
}
return buildPath(last, parent)
}

private fun escapeJsonPointer(propertyName: String): String {
if (propertyName.contains(SEPARATOR) || propertyName.contains(QUOTATION)) {
return buildString(capacity = propertyName.length + 1) {
for (ch in propertyName) {
when (ch) {
QUOTATION -> append(QUOTATION).append(QUOTATION_ESCAPE)
SEPARATOR -> append(QUOTATION).append(SEPARATOR_ESCAPE)
QUOTATION -> append(QUOTATION).append(QUOTATION_ESCAPE)
else -> append(ch)
}
}
},
)

override fun toString(): String {
return if (pathOffset <= 0) {
fullPath
} else {
fullPath.substring(pathOffset)
}
}
return propertyName
}

override fun equals(other: Any?): Boolean {
Expand All @@ -74,13 +100,34 @@

other as JsonPointer

if (fullPath != other.fullPath) return false
return pathOffset == other.pathOffset
var node = this
var otherNode = other
while (node is SegmentPointer && otherNode is SegmentPointer) {
if (node.propertyName != otherNode.propertyName) {
return false
}
node = node.next
otherNode = otherNode.next
}
return node is EmptyPointer && otherNode is EmptyPointer
}

override fun hashCode(): Int {
var result = fullPath.hashCode()
result = 31 * result + pathOffset
if (hash != 0) {
return hash
}
var result = 31
var node = this
while (node is SegmentPointer) {
result = 31 * result + node.propertyName.hashCode()
node = node.next
}
if (result == 0) {
// just in case if for some reason the resulting has is zero
// this way we won't recalculate it again
result = 31

Check warning on line 128 in src/commonMain/kotlin/io/github/optimumcode/json/pointer/JsonPointer.kt

View check run for this annotation

Codecov / codecov/patch

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

Added line #L128 was not covered by tests
}
hash = result
return result
}

Expand Down Expand Up @@ -118,42 +165,32 @@
}
}

@JvmStatic
private fun parseExpression(expr: String): JsonPointer {
class PointerParent(
val parent: PointerParent?,
val startOffset: Int,
val segment: String,
)
private class PointerParent(
val parent: PointerParent?,
val segment: String,
)

fun buildPath(
start: Int,
lastSegment: String,
parent: PointerParent?,
): JsonPointer {
var curr =
SegmentPointer(
expr,
start,
lastSegment,
EmptyPointer,
)
var parentValue = parent
while (parentValue != null) {
curr =
parentValue.run {
SegmentPointer(
expr,
startOffset,
segment,
curr,
)
}
parentValue = parentValue.parent
}
return curr
private fun buildPath(
lastSegment: SegmentPointer,
parent: PointerParent?,
): JsonPointer {
var curr = lastSegment
var parentValue = parent
while (parentValue != null) {
curr =
parentValue.run {
SegmentPointer(
segment,
curr,
)
}
parentValue = parentValue.parent
}
return curr
}

@JvmStatic
private fun parseExpression(expr: String): JsonPointer {
var parent: PointerParent? = null

var offset = 1 // skip contextual slash
Expand All @@ -162,7 +199,7 @@
while (offset < end) {
val currentChar = expr[offset]
if (currentChar == SEPARATOR) {
parent = PointerParent(parent, start, expr.substring(start + 1, offset))
parent = PointerParent(parent, expr.substring(start + 1, offset))
start = offset
offset++
continue
Expand All @@ -173,15 +210,15 @@
offset = builder.appendEscapedSegment(expr, start + 1, offset)
val segment = builder.toString()
if (offset < 0) {
return buildPath(start, segment, parent)
return buildPath(SegmentPointer(segment), parent)
}
parent = PointerParent(parent, start, segment)
parent = PointerParent(parent, segment)
start = offset
offset++
continue
}
}
return buildPath(start, expr.substring(start + 1), parent)
return buildPath(SegmentPointer(expr.substring(start + 1)), parent)
}
}
}
Expand Down Expand Up @@ -229,14 +266,12 @@
append(result)
}

internal object EmptyPointer : JsonPointer(fullPath = "", pathOffset = 0)
internal object EmptyPointer : JsonPointer()

internal class SegmentPointer(
fullPath: String,
pathOffset: Int,
segment: String,
next: JsonPointer? = null,
) : JsonPointer(fullPath, pathOffset, next) {
override val next: JsonPointer = EmptyPointer,
) : JsonPointer(next) {
val propertyName: String = segment
val index: Int = parseIndex(segment)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,7 @@ public operator fun JsonPointer.plus(otherPointer: JsonPointer): JsonPointer {
if (otherPointer is EmptyPointer) {
return this
}
return JsonPointer(
buildString {
append(this@plus.toString())
append(otherPointer.toString())
},
)
return this.insertLast(otherPointer as SegmentPointer)
}

/**
Expand Down Expand Up @@ -165,7 +160,7 @@ public tailrec fun JsonElement.at(pointer: JsonPointer): JsonElement? {
is EmptyPointer -> this
is SegmentPointer -> {
val next = atPointer(pointer)
next?.at(pointer.next ?: error("pointer $pointer does not has next segment and is not EmptyPointer"))
next?.at(pointer.next)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ internal object ReferenceValidator {
schemaPath to refId
}

val circledReferences = hashSetOf<CircledReference>()
val circledReferences = linkedSetOf<CircledReference>()

val refsByBaseId: Map<Uri, Set<JsonPointer>> =
referencesWithPath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,10 @@ class JsonPointerTest : FunSpec() {
val pointer = JsonPointer("/first/second")
assertSoftly {
pointer.assertSegment(property = "first")
pointer.next shouldNotBe null
pointer.next!!.assertSegment(property = "second")
pointer.next.next shouldBe EmptyPointer
val next = pointer.next
next shouldNotBe null
next!!.assertSegment(property = "second")
next.next shouldBe EmptyPointer
}
}

Expand Down Expand Up @@ -111,6 +112,39 @@ class JsonPointerTest : FunSpec() {
pointer.toString() shouldBe "/test1//test2"
}
}

listOf(
JsonPointer.ROOT to JsonPointer("/test"),
JsonPointer("/test") to JsonPointer.ROOT,
JsonPointer("/test1") to JsonPointer("/test2"),
JsonPointer("/test/another") to JsonPointer("/test"),
JsonPointer("/test") to JsonPointer("/test/another"),
).forEach { (a, b) ->
test("'$a' not equal '$b'") {
a shouldNotBe b
}
}

test("negative index is not allowed") {
shouldThrow<IllegalArgumentException> {
JsonPointer.ROOT.atIndex(-1)
}.message shouldBe "negative index: -1"
}

test("~2 is not escaping") {
JsonPointer("/~2test")
.assertSegment("~2test")
}

test("~ in the end is not escaping") {
JsonPointer("/~")
.assertSegment("~")
}

test("property that starts with number does not result in index") {
JsonPointer("/1test")
.assertSegment("1test", index = -1)
}
}

private fun JsonPointer.assertSegment(
Expand Down
Loading
Loading