Skip to content
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
5 changes: 5 additions & 0 deletions release-notes/CREDITS
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,8 @@ Max Paulus (@maxpaulus43)
Antonin Janec (@xtonik)
* Contributed #1576: Use Java 17 intrinstics for long multiplication
(3.2.0)

Scott Lewis (@scottslewis)
* Contributed #1637: Add `JsonPointer.startsWith(JsonPointer)` to check
prefix match
(3.3.0)
3 changes: 2 additions & 1 deletion release-notes/VERSION
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ JSON library.

3.3.0 (not yet released)

No changes since 3.2
#1637: Add `JsonPointer.startsWith(JsonPointer)` to check prefix match
(contributed by @scottslewis)

3.2.1 (10-Jul-2026)

Expand Down
51 changes: 51 additions & 0 deletions src/main/java/tools/jackson/core/JsonPointer.java
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,57 @@ private final boolean _compare(String str1, int offset1,
return true;
}

/**
* Method for checking whether this pointer starts with (that is, is prefixed by)
* the given other pointer. Every pointer starts with the "empty" pointer, and
* with itself.
*<p>
* Matching is done on decoded logical segments -- each segment must match either
* as same property name or as same element index -- and not as a raw String prefix.
* Note that this means results may differ from {@link #equals}, which compares the
* String representation: two pointers that differ only by escaping of an invalid
* escape sequence (like {@code "/a~0b"} vs {@code "/a~b"}) decode to the same
* segment and hence match here, but are not {@code equals}.
*
* @param other Pointer to check as prefix; {@code null} results in {@code false}
*
* @return {@code True} if this pointer starts with the given other pointer;
* {@code false} otherwise (including case of {@code null} argument)
*
* @since 3.3
*/
public boolean startsWith(JsonPointer other) {
if (other == null) {
return false;
}
if (other == EMPTY) {
return true;
}
JsonPointer a = this;
JsonPointer b = other;
while (b != EMPTY) {
if (a == EMPTY) {
// 'other' has more segments than 'this'
return false;
}
// Compare element index if present in 'b'
if (b._matchingElementIndex >= 0) {
if (a._matchingElementIndex != b._matchingElementIndex) {
return false;
}
} else {
// Compare property names (may be empty string; only EMPTY has `null`
// name and that case was already handled above)
if (!a._matchingPropertyName.equals(b._matchingPropertyName)) {
return false;
}
}
a = a._nextSegment;
b = b._nextSegment;
}
return true;
}

/*
/**********************************************************************
/* Internal methods
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
package tools.jackson.core.unittest.jsonptr;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonPointer;
import tools.jackson.core.JsonToken;
import tools.jackson.core.ObjectReadContext;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.unittest.*;

import static org.junit.jupiter.api.Assertions.*;

// Tests for [core#1637]: `JsonPointer.startsWith(JsonPointer)`
class JsonPointerStartsWithTest extends JacksonCoreTestBase
{
private final JsonFactory JSON_F = new JsonFactory();

@Test
void startsWithEmpty() {
JsonPointer ptr = JsonPointer.compile("/a/b/c");
assertTrue(ptr.startsWith(JsonPointer.empty()), "Any pointer should start with the empty pointer");
assertTrue(JsonPointer.empty().startsWith(JsonPointer.empty()), "Empty pointer should start with empty pointer");
}

@Test
void startsWithNull() {
JsonPointer ptr = JsonPointer.compile("/a/b/c");
assertFalse(ptr.startsWith(null), "Should return false for null input");
}

@ParameterizedTest
@CsvSource({
"/a/b/c, /a",
"/a/b/c, /a/b",
"/a/b/c, /a/b/c",
"/1/2/3, /1",
"/1/2/3, /1/2",
"/prop/0/leaf, /prop/0",
"/~1slash/~0tilde, /~1slash"
})
void startsWithValidPrefix(String full, String prefix) {
JsonPointer fullPtr = JsonPointer.compile(full);
JsonPointer prefixPtr = JsonPointer.compile(prefix);
assertTrue(fullPtr.startsWith(prefixPtr),
String.format("Pointer '%s' should start with '%s'", full, prefix));
}

@ParameterizedTest
@CsvSource({
"/a/b/c, /b",
"/a/b/c, /a/c",
"/a/b/c, /a/b/c/d",
"/1/2/3, /2",
"/1/2/3, /1/3",
"/1/2/3, /1/2/3/4",
"/prop/0, /prop/1",
"/a, /b",
// Matching is by segment, not by raw String prefix:
"/abc, /ab",
"/abc/d, /ab",
"/abc/d, /abc/d/e",
"/12/3, /1"
})
void startsWithInvalidPrefix(String full, String prefix) {
JsonPointer fullPtr = JsonPointer.compile(full);
JsonPointer prefixPtr = JsonPointer.compile(prefix);
assertFalse(fullPtr.startsWith(prefixPtr),
String.format("Pointer '%s' should NOT start with '%s'", full, prefix));
}

@Test
void startsWithEscaped() {
JsonPointer fullPtr = JsonPointer.compile("/~1part1/~0part2/end");

assertTrue(fullPtr.startsWith(JsonPointer.compile("/~1part1")));
assertTrue(fullPtr.startsWith(JsonPointer.compile("/~1part1/~0part2")));

// Mismatch in escaping
assertFalse(fullPtr.startsWith(JsonPointer.compile("/part1")));
// Escaped slash is part of one segment, not a segment separator
assertFalse(fullPtr.startsWith(JsonPointer.compile("/")));
}

// Matching is on decoded segments, so equal-decoding pointers match even
// when their String representations (and hence `equals()`) differ
@Test
void startsWithNotSameAsEquals() {
JsonPointer valid = JsonPointer.compile("/a~0b");
// "~b" is not a valid escape and is decoded as-is; same segment as above
JsonPointer invalidEsc = JsonPointer.compile("/a~b");

assertEquals("a~b", valid.getMatchingProperty());
assertEquals("a~b", invalidEsc.getMatchingProperty());
assertNotEquals(valid, invalidEsc);

assertTrue(valid.startsWith(invalidEsc));
assertTrue(invalidEsc.startsWith(valid));
}

@Test
void startsWithTypeSafety() {
// "/0" is index 0, "/00" is property name "00"
JsonPointer indexPtr = JsonPointer.compile("/0/next");
JsonPointer propPtr = JsonPointer.compile("/00/next");

assertTrue(indexPtr.startsWith(JsonPointer.compile("/0")));
assertFalse(indexPtr.startsWith(JsonPointer.compile("/00")));

assertTrue(propPtr.startsWith(JsonPointer.compile("/00")));
assertFalse(propPtr.startsWith(JsonPointer.compile("/0")));
}

@Test
void startsWithLongerPrefix() {
JsonPointer ptr = JsonPointer.compile("/a/b");
JsonPointer longer = JsonPointer.compile("/a/b/c");
assertFalse(ptr.startsWith(longer), "Pointer should not start with a longer pointer");
}

// [core#788]: empty String ("") is a valid property name, distinct from EMPTY pointer
@Test
void startsWithEmptyStringProperty() {
// "/" is a single segment matching property with empty-String name
JsonPointer emptyProp = JsonPointer.compile("/");
JsonPointer emptyPropChild = JsonPointer.compile("//leaf");

assertTrue(emptyProp.startsWith(JsonPointer.compile("/")));
assertTrue(emptyPropChild.startsWith(JsonPointer.compile("/")));
assertTrue(emptyPropChild.startsWith(JsonPointer.compile("//leaf")));

// empty-String property "/" is NOT the same as the EMPTY (root) pointer as a prefix source:
assertFalse(JsonPointer.empty().startsWith(emptyProp));
// ...but every pointer (incl. "/") starts with the EMPTY pointer
assertTrue(emptyProp.startsWith(JsonPointer.empty()));
}

// Pointers constructed by mutant factories build fresh segment chains: verify
// those work as both receiver and argument
@Test
void startsWithDerivedPointers() {
final JsonPointer full = JsonPointer.compile("/a/b/c/d");

JsonPointer head = full.head(); // "/a/b/c"
assertEquals("/a/b/c", head.toString());
assertTrue(full.startsWith(head));
assertTrue(head.startsWith(head.head()));
assertFalse(head.startsWith(full));

JsonPointer tail = full.tail(); // "/b/c/d"
assertTrue(tail.startsWith(JsonPointer.compile("/b/c")));
assertFalse(tail.startsWith(JsonPointer.compile("/a")));

JsonPointer appended = JsonPointer.compile("/a/b").append(JsonPointer.compile("/c"));
assertTrue(appended.startsWith(JsonPointer.compile("/a/b")));
assertTrue(full.startsWith(appended));

JsonPointer built = JsonPointer.compile("/a").appendIndex(3).appendProperty("x/y");
assertTrue(built.startsWith(JsonPointer.compile("/a/3")));
assertTrue(built.startsWith(JsonPointer.compile("/a").appendIndex(3)));
assertTrue(built.startsWith(built));
// "/03" is a property name, not index 3
assertFalse(built.startsWith(JsonPointer.compile("/a/03")));
}

// Pointers from parsing context are built via `JsonPointer.forPath()`, another
// separate construction path
@Test
void startsWithPointerFromContext() throws Exception
{
final String DOC = a2q("{'ob':{'array':[1,{'leaf':true}]}}");

try (JsonParser p = JSON_F.createParser(ObjectReadContext.empty(), DOC)) {
while (p.nextToken() != null) {
if (p.currentToken() == JsonToken.VALUE_TRUE) {
break;
}
}
JsonPointer ptr = p.streamReadContext().pathAsPointer();
assertEquals("/ob/array/1/leaf", ptr.toString());

assertTrue(ptr.startsWith(JsonPointer.empty()));
assertTrue(ptr.startsWith(JsonPointer.compile("/ob")));
assertTrue(ptr.startsWith(JsonPointer.compile("/ob/array/1")));
assertTrue(ptr.startsWith(ptr));
assertFalse(ptr.startsWith(JsonPointer.compile("/ob/array/0")));
// and works as prefix argument as well:
assertTrue(JsonPointer.compile("/ob/array/1/leaf/deeper").startsWith(ptr));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import java.io.StringWriter;

import org.junit.jupiter.api.Test;

import tools.jackson.core.JsonGenerator;
import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonPointer;
Expand All @@ -25,7 +27,8 @@ public class PointerFromContextTest extends JacksonCoreTestBase

private final JsonPointer EMPTY_PTR = JsonPointer.empty();

public void testViaParser() throws Exception
@Test
void testViaParser() throws Exception
{
final String SIMPLE = """
{"a":123,"array":[1,2,[3],5,{"obInArray":4}],"ob":{"first":[false,true],"second":{"sub":37}},"b":true}""";
Expand Down Expand Up @@ -110,7 +113,8 @@ public void testViaParser() throws Exception
p.close();
}

public void testViaGenerator() throws Exception
@Test
void testViaGenerator() throws Exception
{
StringWriter w = new StringWriter();
JsonGenerator g = JSON_F.createGenerator(ObjectWriteContext.empty(), w);
Expand Down Expand Up @@ -154,7 +158,8 @@ public void testViaGenerator() throws Exception
/**********************************************************
*/

public void testParserWithRoot() throws Exception
@Test
void testParserWithRoot() throws Exception
{
final String JSON = """
{"a":1,"b":3}\n{"a":5,"c":[1,2]}\n[1,2]\n""";
Expand Down Expand Up @@ -217,7 +222,8 @@ public void testParserWithRoot() throws Exception
p.close();
}

public void testGeneratorWithRoot() throws Exception
@Test
void testGeneratorWithRoot() throws Exception
{
StringWriter w = new StringWriter();
JsonGenerator g = JSON_F.createGenerator(ObjectWriteContext.empty(), w);
Expand Down