Skip to content

GH-139: Allow pointing to array elements via a reference field #203

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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
34 changes: 31 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
[![CircleCI](https://circleci.com/gh/flipkart-incubator/zjsonpatch/tree/master.svg?style=svg)](https://circleci.com/gh/flipkart-incubator/zjsonpatch/tree/master) [![Join the chat at https://gitter.im/zjsonpatch/community](https://badges.gitter.im/zjsonpatch/community.svg)](https://gitter.im/zjsonpatch/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)

# This is an implementation of [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) written in Java.
# This is an implementation of [RFC 6902 JSON Patch](https://datatracker.ietf.org/doc/html/rfc6902) written in Java with extended JSON pointer.

## Description & Use-Cases
- Java Library to find / apply JSON Patches according to [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902).
- JSON Patch defines a JSON document structure for representing changes to a JSON document.
- It can be used to avoid sending a whole document when only a part has changed, thus reducing network bandwidth requirements if data (in JSON format) is required to send across multiple systems over network or in case of multi DC transfer.
- When used in combination with the HTTP PATCH method as per [RFC 5789 HTTP PATCH](https://datatracker.ietf.org/doc/html/rfc5789), it will do partial updates for HTTP APIs in a standard way.
- When used in combination with the HTTP PATCH method as per [RFC 5789 HTTP PATCH](https://datatracker.ietf.org/doc/html/rfc5789), it will do partial updates for HTTP APIs in a standard way.
- Extended JSON pointer functionality (i.e. reference array elements via a key): `/array/id=123/data`


### Compatible with : Java 7+ versions
Expand All @@ -17,7 +18,7 @@ Package | Class, % | Method, % | Line, % |
all classes | 100% (6/ 6) | 93.6% (44/ 47) | 96.2% (332/ 345) |

## Complexity
- To find JsonPatch : Ω(N+M) ,N and M represents number of keys in first and second json respectively / O(summation of la*lb) where la , lb represents JSON array of length la / lb of against same key in first and second JSON ,since LCS is used to find difference between 2 JSON arrays there of order of quadratic.
- To find JsonPatch : Ω(N+M), N and M represents number of keys in first and second json respectively / O(summation of la*lb) where la , lb represents JSON array of length la / lb of against same key in first and second JSON ,since LCS is used to find difference between 2 JSON arrays there of order of quadratic.
- To Optimize Diffs ( compact move and remove into Move ) : Ω(D) / O(D*D) where D represents number of diffs obtained before compaction into Move operation.
- To Apply Diff : O(D) where D represents number of diffs

Expand Down Expand Up @@ -79,6 +80,33 @@ Following patch will be returned:
```
here `"op"` specifies the operation (`"move"`), `"from"` specifies the path from where the value should be moved, and `"path"` specifies where value should be moved. The value that is moved is taken as the content at the `"from"` path.

### Extended JSON Pointer Example
JSON
```json
{
"a": [
{
"id": 1,
"data": "abc"
},
{
"id": 2,
"data": "def"
}
]
}
```

JSON path
```jsonpath
/a/id=2/data
```

Following JSON would be returned
```json
"def"
```

### Apply Json Patch In-Place
```xml
JsonPatch.applyInPlace(JsonNode patch, JsonNode source);
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

<groupId>com.flipkart.zjsonpatch</groupId>
<artifactId>zjsonpatch</artifactId>
<version>0.4.17-SNAPSHOT</version>
<version>0.5.0-SNAPSHOT</version>
<packaging>jar</packaging>

<name>zjsonpatch</name>
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/flipkart/zjsonpatch/JsonDiff.java
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ private static JsonPointer updatePathWithCounters(List<Integer> counters, JsonPo
int value = counters.get(i);
if (value != 0) {
int currValue = tokens.get(i).getIndex();
tokens.set(i, new JsonPointer.RefToken(Integer.toString(currValue + value)));
tokens.set(i, JsonPointer.RefToken.parse(Integer.toString(currValue + value)));
}
}
return new JsonPointer(tokens);
Expand Down
147 changes: 123 additions & 24 deletions src/main/java/com/flipkart/zjsonpatch/JsonPointer.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

Expand Down Expand Up @@ -82,16 +83,20 @@ public static JsonPointer parse(String path) throws IllegalArgumentException {
// Escape sequences
case '~':
switch (path.charAt(++i)) {
case '0': reftoken.append('~'); break;
case '1': reftoken.append('/'); break;
case '0':
case '1':
case '2':
reftoken.append('~');
reftoken.append(path.charAt(i));
break;
default:
throw new IllegalArgumentException("Invalid escape sequence ~" + path.charAt(i) + " at index " + i);
}
break;

// New reftoken
case '/':
result.add(new RefToken(reftoken.toString()));
result.add(RefToken.parse(reftoken.toString()));
reftoken.setLength(0);
break;

Expand Down Expand Up @@ -124,7 +129,7 @@ public boolean isRoot() {
*/
JsonPointer append(String field) {
RefToken[] newTokens = Arrays.copyOf(tokens, tokens.length + 1);
newTokens[tokens.length] = new RefToken(field);
newTokens[tokens.length] = new RefToken(field, null, null);
return new JsonPointer(newTokens);
}

Expand All @@ -135,7 +140,9 @@ JsonPointer append(String field) {
* @return The new {@link JsonPointer} instance.
*/
JsonPointer append(int index) {
return append(Integer.toString(index));
RefToken[] newTokens = Arrays.copyOf(tokens, tokens.length + 1);
newTokens[tokens.length] = new RefToken(Integer.toString(index), index, null);
return new JsonPointer(newTokens);
}

/** Returns the number of reference tokens comprising this instance. */
Expand Down Expand Up @@ -226,11 +233,27 @@ public JsonNode evaluate(final JsonNode document) throws JsonPointerEvaluationEx
final RefToken token = tokens[idx];

if (current.isArray()) {
if (!token.isArrayIndex())
if (token.isArrayIndex()) {
if (token.getIndex() == LAST_INDEX || token.getIndex() >= current.size())
error(idx, "Array index " + token + " is out of bounds", document);
current = current.get(token.getIndex());
} else if (token.isArrayKeyRef()) {
KeyRef keyRef = token.getKeyRef();
JsonNode foundArrayNode = null;
for (int arrayIdx = 0; arrayIdx < current.size(); ++arrayIdx) {
JsonNode arrayNode = current.get(arrayIdx);
if (matches(keyRef, arrayNode)) {
foundArrayNode = arrayNode;
break;
}
}
if (foundArrayNode == null) {
error(idx, "Array has no matching object for key reference " + token, document);
}
current = foundArrayNode;
} else {
error(idx, "Can't reference field \"" + token.getField() + "\" on array", document);
if (token.getIndex() == LAST_INDEX || token.getIndex() >= current.size())
error(idx, "Array index " + token.toString() + " is out of bounds", document);
current = current.get(token.getIndex());
}
}
else if (current.isObject()) {
if (!current.has(token.getField()))
Expand All @@ -244,6 +267,19 @@ else if (current.isObject()) {
return current;
}

private boolean matches(KeyRef keyRef, JsonNode arrayNode) {
boolean matches = false;
if (arrayNode.has(keyRef.key)) {
JsonNode valueNode = arrayNode.get(keyRef.key);
if (valueNode.isTextual()) {
matches = Objects.equals(keyRef.value, valueNode.textValue());
} else if (valueNode.isNumber() || valueNode.isBoolean()) {
matches = Objects.equals(keyRef.value, valueNode.toString());
}
}
return matches;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
Expand All @@ -262,61 +298,99 @@ public int hashCode() {

/** Represents a single JSON Pointer reference token. */
static class RefToken {
private String decodedToken;
transient private Integer index = null;
private final String decodedToken;
private final Integer index;
private final KeyRef keyRef;

public RefToken(String decodedToken) {
private RefToken(String decodedToken, Integer arrayIndex, KeyRef arrayKeyRef) {
if (decodedToken == null) throw new IllegalArgumentException("Token can't be null");
this.decodedToken = decodedToken;
this.index = arrayIndex;
this.keyRef = arrayKeyRef;
}

private static final Pattern DECODED_TILDA_PATTERN = Pattern.compile("~0");
private static final Pattern DECODED_SLASH_PATTERN = Pattern.compile("~1");
private static final Pattern DECODED_EQUALS_PATTERN = Pattern.compile("~2");

private static String decodePath(Object object) {
String path = object.toString(); // see http://tools.ietf.org/html/rfc6901#section-4
path = DECODED_SLASH_PATTERN.matcher(path).replaceAll("/");
return DECODED_TILDA_PATTERN.matcher(path).replaceAll("~");
path = DECODED_TILDA_PATTERN.matcher(path).replaceAll("~");
return DECODED_EQUALS_PATTERN.matcher(path).replaceAll("=");
}

private static final Pattern ENCODED_TILDA_PATTERN = Pattern.compile("~");
private static final Pattern ENCODED_SLASH_PATTERN = Pattern.compile("/");
private static final Pattern ENCODED_EQUALS_PATTERN = Pattern.compile("=");

private static String encodePath(Object object) {
String path = object.toString(); // see http://tools.ietf.org/html/rfc6901#section-4
path = ENCODED_TILDA_PATTERN.matcher(path).replaceAll("~0");
return ENCODED_SLASH_PATTERN.matcher(path).replaceAll("~1");
path = ENCODED_SLASH_PATTERN.matcher(path).replaceAll("~1");
return ENCODED_EQUALS_PATTERN.matcher(path).replaceAll("~2");
}

private static final Pattern VALID_ARRAY_IND = Pattern.compile("-|0|(?:[1-9][0-9]*)");

private static final Pattern VALID_ARRAY_KEY_REF = Pattern.compile("([^=]+)=([^=]+)");

public static RefToken parse(String rawToken) {
if (rawToken == null) throw new IllegalArgumentException("Token can't be null");
return new RefToken(decodePath(rawToken));

Integer index = null;
Matcher indexMatcher = VALID_ARRAY_IND.matcher(rawToken);
if (indexMatcher.matches()) {
if (indexMatcher.group().equals("-")) {
index = LAST_INDEX;
} else {
try {
int validInt = Integer.parseInt(indexMatcher.group());
index = validInt;
} catch (NumberFormatException ignore) {}
}
}

KeyRef keyRef = null;
Matcher arrayKeyRefMatcher = VALID_ARRAY_KEY_REF.matcher(rawToken);
if (arrayKeyRefMatcher.matches()) {
keyRef = new KeyRef(
decodePath(arrayKeyRefMatcher.group(1)),
decodePath(arrayKeyRefMatcher.group(2))
);
}
return new RefToken(decodePath(rawToken), index, keyRef);
}

public boolean isArrayIndex() {
if (index != null) return true;
Matcher matcher = VALID_ARRAY_IND.matcher(decodedToken);
if (matcher.matches()) {
index = matcher.group().equals("-") ? LAST_INDEX : Integer.parseInt(matcher.group());
return true;
}
return false;
return index != null;
}

public boolean isArrayKeyRef() {
return keyRef != null;
}

public int getIndex() {
if (!isArrayIndex()) throw new IllegalStateException("Object operation on array target");
if (!isArrayIndex()) throw new IllegalStateException("Object operation on array index target");
return index;
}

public KeyRef getKeyRef() {
if (!isArrayKeyRef()) throw new IllegalStateException("Object operation on array key ref target");
return keyRef;
}

public String getField() {
return decodedToken;
}

@Override
public String toString() {
return encodePath(decodedToken);
if (isArrayKeyRef()) {
return encodePath(keyRef.key) + "=" + encodePath(keyRef.value);
} else {
return encodePath(decodedToken);
}
}

@Override
Expand All @@ -335,6 +409,31 @@ public int hashCode() {
}
}

static class KeyRef {
private String key;
private String value;

public KeyRef(String key, String value) {
this.key = key;
this.value = value;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;

KeyRef keyRef = (KeyRef) o;

return Objects.equals(key, keyRef.key) && Objects.equals(value, keyRef.value);
}

@Override
public int hashCode() {
return Objects.hash(key, value);
}
}
Comment on lines +412 to +435
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Make KeyRef fields final for immutability.

The key and value fields in the KeyRef class should be declared as final to ensure immutability, consistent with the immutability documentation of the RefToken class.

static class KeyRef {
-   private String key;
-   private String value;
+   private final String key;
+   private final String value;

    public KeyRef(String key, String value) {
        this.key = key;
        this.value = value;
    }
    // ...
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static class KeyRef {
private String key;
private String value;
public KeyRef(String key, String value) {
this.key = key;
this.value = value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
KeyRef keyRef = (KeyRef) o;
return Objects.equals(key, keyRef.key) && Objects.equals(value, keyRef.value);
}
@Override
public int hashCode() {
return Objects.hash(key, value);
}
}
static class KeyRef {
private final String key;
private final String value;
public KeyRef(String key, String value) {
this.key = key;
this.value = value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
KeyRef keyRef = (KeyRef) o;
return Objects.equals(key, keyRef.key) && Objects.equals(value, keyRef.value);
}
@Override
public int hashCode() {
return Objects.hash(key, value);
}
}
🤖 Prompt for AI Agents
In src/main/java/com/flipkart/zjsonpatch/JsonPointer.java between lines 412 and
435, the KeyRef class fields key and value are not declared final, which
compromises immutability. To fix this, declare both key and value fields as
final to ensure they cannot be modified after construction, aligning with the
immutability principle stated for RefToken.


/**
* Represents an array index pointing past the end of the array.
*
Expand Down
Loading