diff --git a/release-notes/CREDITS b/release-notes/CREDITS index 7033057d27..cdbc1b16a9 100644 --- a/release-notes/CREDITS +++ b/release-notes/CREDITS @@ -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) diff --git a/release-notes/VERSION b/release-notes/VERSION index ce535c8c8c..389b17a234 100644 --- a/release-notes/VERSION +++ b/release-notes/VERSION @@ -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) diff --git a/src/main/java/tools/jackson/core/JsonPointer.java b/src/main/java/tools/jackson/core/JsonPointer.java index f9c6916d0c..09ea29e63e 100644 --- a/src/main/java/tools/jackson/core/JsonPointer.java +++ b/src/main/java/tools/jackson/core/JsonPointer.java @@ -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. + *
+ * 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 diff --git a/src/test/java/tools/jackson/core/unittest/jsonptr/JsonPointerStartsWithTest.java b/src/test/java/tools/jackson/core/unittest/jsonptr/JsonPointerStartsWithTest.java new file mode 100644 index 0000000000..cb3fb796d6 --- /dev/null +++ b/src/test/java/tools/jackson/core/unittest/jsonptr/JsonPointerStartsWithTest.java @@ -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)); + } + } +} diff --git a/src/test/java/tools/jackson/core/unittest/jsonptr/PointerFromContextTest.java b/src/test/java/tools/jackson/core/unittest/jsonptr/PointerFromContextTest.java index 8a6a2a45a8..60ae0fe88d 100644 --- a/src/test/java/tools/jackson/core/unittest/jsonptr/PointerFromContextTest.java +++ b/src/test/java/tools/jackson/core/unittest/jsonptr/PointerFromContextTest.java @@ -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; @@ -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}"""; @@ -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); @@ -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"""; @@ -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);