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
35 changes: 35 additions & 0 deletions src/main/java/eu/europa/ted/eforms/xpath/Simplification.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package eu.europa.ted.eforms.xpath;

/**
* How much a joined path is shortened.
*
* <p>
* Where a path goes down to a step and immediately comes back up, the two steps can be removed:
* {@code a/b/../c} becomes {@code a/c}. The result is shorter but it does not mean the same thing.
* The long form selects nothing when {@code b} is absent from the document, whereas the short form
* selects {@code c} whether {@code b} is there or not.
*
* <p>
* That difference matters to some callers and not to others, so it is theirs to choose.
*/
public enum Simplification {
/**
* Every step is kept as it was written. The path stays longer, and it selects nothing unless
* every step along the way is present in the document.
*/
NONE,

/**
* A step and a following parent step cancel each other out, unless the step carries a predicate.
* Whatever the predicate says is therefore kept, but the requirement that an unpredicated step be
* present is lost.
*/
PRESERVE_PREDICATES,

/**
* A step and a following parent step cancel each other out even when the step carries a
* predicate. The predicate goes with the step, so both the requirement that the step be present
* and whatever its predicate said are lost.
*/
FULL
}
49 changes: 41 additions & 8 deletions src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Queue;
import java.util.stream.Collectors;

Expand Down Expand Up @@ -58,7 +59,36 @@ public static String addAxis(final String axis, final String path) {
return steps.stream().map(s -> s.toString()).collect(Collectors.joining("/"));
}

/**
* Joins two paths, the second being relative to the first, and shortens the result where a step
* is immediately followed by a step going back up.
*
* @deprecated This picks {@link Simplification#PRESERVE_PREDICATES} without saying so, and the
* choice is not a detail: shortening does not preserve meaning, since
* {@code a/b/../c} selects nothing when {@code b} is absent from the document while
* {@code a/c} selects {@code c} regardless. Call
* {@link #join(String, String, Simplification)} and say which of the three you want.
* This method will be removed in the next major version.
*/
@Deprecated(since = "1.9.0", forRemoval = true)
public static String join(final String first, final String second) {
return join(first, second, Simplification.PRESERVE_PREDICATES);
}

/**
* Joins two paths, the second being relative to the first, shortening the result as far as the
* caller asks for.
*
* @param simplification how much of the result to shorten, and therefore how much of what the two
* paths said is kept. See {@link Simplification}; none of its settings other than
* {@link Simplification#NONE} preserves the meaning of the paths given.
*/
public static String join(final String first, final String second,
final Simplification simplification) {
// Each setting keeps a different amount of what the two paths said, so there is no answer to
// give when the caller has not said which one they want.
Objects.requireNonNull(simplification,
"Say how much of the joined path may be shortened; see Simplification.");

if (first == null || first.trim().isEmpty()) {
return second;
Expand All @@ -73,7 +103,8 @@ public static String join(final String first, final String second) {
LinkedList<XPathStep> secondPartSteps = new LinkedList<>(parse(second).getSteps());

final XPathAnchor anchor = firstPart.getAnchor();
final String joined = getJoinedXPath(firstPartSteps, secondPartSteps, anchor);
final String joined =
getJoinedXPath(firstPartSteps, secondPartSteps, anchor, simplification);

if (joined.isEmpty()) {
// The back-steps consumed both parts, so the join resolves to where it started from: the
Expand Down Expand Up @@ -173,23 +204,25 @@ private static String getContextualizedXpath(Queue<XPathStep> contextQueue,
}

private static String getJoinedXPath(LinkedList<XPathStep> first,
final LinkedList<XPathStep> second, final XPathAnchor anchor) {
final LinkedList<XPathStep> second, final XPathAnchor anchor,
final Simplification simplification) {

// A path that searches from the root matches at any depth, so the position of its first step is
// not known. Cancelling that step against a parent step would claim a position it does not
// have, so it is left in place.
final int minimumStepsToKeep = anchor == XPathAnchor.DESCENDANT_FROM_ROOT ? 1 : 0;
while (!second.isEmpty() && first.size() > minimumStepsToKeep
while (simplification != Simplification.NONE && !second.isEmpty()
&& first.size() > minimumStepsToKeep
&& second.getFirst().getStepText().equals("..")
// Only a step that went somewhere can be cancelled by one coming back. A step that only
// moves about went nowhere to return from, whichever of its spellings was used: ".." and
// "parent::node()" are the same step, as are "." and "self::node()".
&& !first.getLast().isNavigationStep() && !first.getLast().isVariableStep()
// A step going somewhere and a step coming back cancel out, but only when neither says
// anything about where it went. A predicate on either of them is a condition on the result,
// so a step carrying one is kept and the two are left to stand as they were written.
&& second.getFirst().getPredicates().isEmpty()
&& first.getLast().getPredicates().isEmpty()) {
// A predicate says something about the node the step arrived at. Cancelling the step throws
// that away along with it, so the caller decides whether it may be.
&& (simplification == Simplification.FULL
|| (second.getFirst().getPredicates().isEmpty()
&& first.getLast().getPredicates().isEmpty()))) {
second.removeFirst();
first.removeLast();
}
Expand Down
42 changes: 42 additions & 0 deletions src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,48 @@ void testJoin_MustNotCancelStepsThatCarryPredicates() {
assertEquals("c", XPathProcessor.join("a/b", "../../c"));
}

@Test
void testJoin_SimplificationNone_MustKeepEveryStep() {
// The caller is told nothing may be removed, so the path stands exactly as the two halves were
// written and selects nothing unless every step along the way is present.
assertEquals("a/b/../c", XPathProcessor.join("a/b", "../c", Simplification.NONE));
assertEquals("a/b[x]/../c", XPathProcessor.join("a/b[x]", "../c", Simplification.NONE));
assertEquals("a/b/../../c", XPathProcessor.join("a/b", "../../c", Simplification.NONE));
assertEquals("a/b/c", XPathProcessor.join("a/b", "c", Simplification.NONE));
assertEquals("/a/b/../c", XPathProcessor.join("/a/b", "../c", Simplification.NONE));
}

@Test
void testJoin_SimplificationFull_MustCancelEvenThroughAPredicate() {
// The caller accepts that a cancelled step takes its predicate with it.
assertEquals("a/c", XPathProcessor.join("a/b", "../c", Simplification.FULL));
assertEquals("a/c", XPathProcessor.join("a/b[x]", "../c", Simplification.FULL));
assertEquals("a/c", XPathProcessor.join("a", "..[x]/a/c", Simplification.FULL));
assertEquals("c", XPathProcessor.join("a/b", "../../c", Simplification.FULL));

// A predicate on a step that is not cancelled survives, as it must under every setting.
assertEquals("a[x]/c", XPathProcessor.join("a[x]/b", "../c", Simplification.FULL));
assertEquals("a/b/c[y]", XPathProcessor.join("a/b", "c[y]", Simplification.FULL));
}

@Test
void testJoin_MustRefuseAMissingSimplification() {
// The setting decides how much of what the two paths said survives, so there is no sensible
// answer when it is absent.
assertThrows(NullPointerException.class, () -> XPathProcessor.join("a/b", "../c", null));
}

@Test
void testJoin_MustDefaultToPreservingPredicates() {
// The two-argument form keeps the behaviour it has always had.
for (final String[] p : new String[][] {{"a/b", "../c"}, {"a/b[x]", "../c"},
{"a", "..[x]/b"}, {"a/b", "../../c"}, {"a[x]/b", "../c"}, {"/a/b", "../c"}}) {
assertEquals(XPathProcessor.join(p[0], p[1], Simplification.PRESERVE_PREDICATES),
XPathProcessor.join(p[0], p[1]),
"join(" + p[0] + ", " + p[1] + ") must match PRESERVE_PREDICATES");
}
}

@Test
void testJoin_MustNotCancelAStepThatOnlyMovesAbout() {
// A step that only moves about went nowhere to come back from, so a parent step cannot cancel
Expand Down
Loading