From 7d45338ad76157d589bf8d53b4e0d5039ffad967 Mon Sep 17 00:00:00 2001 From: Ioannis Rosuochatzakis Date: Fri, 28 Aug 2026 03:42:33 +0200 Subject: [PATCH 1/2] TEDEFO-5150: Preserve predicates and always return a valid path when adding an axis --- .../ted/eforms/xpath/XPathListenerImpl.java | 27 ++++++++++++-- .../ted/eforms/xpath/XPathProcessor.java | 31 +++++++++++++--- .../eu/europa/ted/eforms/xpath/XPathStep.java | 37 +++++++++++++++++++ .../ted/eforms/xpath/XPathProcessorTest.java | 36 ++++++++++++++++++ .../ted/eforms/xpath/XPathStepTest.java | 25 +++++++++++++ 5 files changed, 147 insertions(+), 9 deletions(-) diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java index 0cf84a8..19e2415 100644 --- a/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java +++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java @@ -45,7 +45,7 @@ public XPathInfo parse(String xpathInput) { walker.walk(this, tree); steps.stream().forEach(s -> { - XPathStep step = new XPathStep(s.stepText, s.predicates); + XPathStep step = new XPathStep(s.stepText, s.predicates, s.nodeTest); xpathInfo.addStep(step); }); @@ -163,26 +163,45 @@ private Boolean inPredicateMode() { return inPredicate > 0; } + /** + * Whether the step is written as a bare node test, which the grammar spells out as + * {@code abbrevforwardstep : AT? nodetest}. That is the only form an axis can be followed by: a + * reverse step, a step carrying an axis of its own and an attribute are all steps, but none of + * them can be written after {@code axis::}. + */ + private static boolean isBareNodeTest(final AxisstepContext ctx) { + return ctx.forwardstep() != null && ctx.forwardstep().abbrevforwardstep() != null + && ctx.forwardstep().abbrevforwardstep().AT() == null; + } + private class StepInfo { String stepText; List predicates; + boolean nodeTest; int a; int b; private StepInfo(AxisstepContext ctx, Function getInputText) { this(ctx.reversestep() != null ? getInputText.apply(ctx.reversestep()) : getInputText.apply(ctx.forwardstep()), ctx.predicatelist().predicate().stream().map(getInputText).collect(Collectors.toList()), - ctx.getSourceInterval()); + ctx.getSourceInterval(), isBareNodeTest(ctx)); } + + /** + * A filter expression is a literal, a variable, a parenthesised expression, the context item or + * a function call. None of them names nodes the way an axis needs. + */ private StepInfo(FilterexprContext ctx, Function getInputText) { this(getInputText.apply(ctx.primaryexpr()), ctx.predicatelist().predicate().stream().map(getInputText).collect(Collectors.toList()), - ctx.getSourceInterval()); + ctx.getSourceInterval(), false); } - private StepInfo(String stepText, List predicates, Interval interval) { + private StepInfo(String stepText, List predicates, Interval interval, + boolean nodeTest) { this.stepText = stepText; this.predicates = predicates; + this.nodeTest = nodeTest; this.a = interval.a; this.b = interval.b; } diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java index 63d57f0..0147b44 100644 --- a/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java +++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Queue; @@ -15,14 +16,34 @@ public static XPathInfo parse(String xpathInput) { return parser.parse(xpathInput); } - public static String addAxis(String axis, String path) { - LinkedList steps = new LinkedList<>(parse(path).getSteps()); - - while (steps.getFirst().getStepText().equals("..")) { + /** + * Rewrites a path so that its first step looks along the given axis instead of along the child + * axis. The path is read as relative to the context the axis is applied from; an absolute path + * cannot keep its anchor, because an axis cannot be followed by a separator, so it is read the + * same way. + * + *

+ * Where the path gives the axis nothing to name, a wildcard stands in for it, so that a valid + * path is always returned for a valid path given. + */ + public static String addAxis(final String axis, final String path) { + final LinkedList steps = new LinkedList<>(parse(path).getSteps()); + + // An axis searches the document from the context node, so moving away from it beforehand makes + // no difference to what is found, and those steps are dropped. A step that carries a predicate + // is kept: the predicate describes the node it arrives at, which is part of what we look for. + while (!steps.isEmpty() && steps.getFirst().isNavigationStep() + && steps.getFirst().getPredicates().isEmpty()) { steps.removeFirst(); } - return axis + "::" + steps.stream().map(s -> s.getStepText()).collect(Collectors.joining("/")); + // An axis has to be followed by the nodes to look for. Where the path does not begin by naming + // any, a wildcard names them instead and the path follows it unchanged. + if (steps.isEmpty() || !steps.getFirst().isNodeTest()) { + steps.addFirst(new XPathStep("*", Collections.emptyList())); + } + + return axis + "::" + steps.stream().map(s -> s.toString()).collect(Collectors.joining("/")); } public static String join(final String first, final String second) { diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java index 862c43e..ff00ad9 100644 --- a/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java +++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java @@ -12,9 +12,29 @@ public class XPathStep implements Comparable { private final String stepText; private final List predicates; + /** + * Whether the step was written as a bare node test, as the parser read it from the grammar. It + * follows from the step itself rather than being a fact of its own, so it takes no part in + * equality or ordering. + */ + private final boolean nodeTest; + + /** + * Builds a step that names the nodes to look for, which is what a step written as an element name + * does. Steps read from a path are built by the parser instead, which tells the two apart from + * the grammar. + */ public XPathStep(String stepText, List predicates) { + this(stepText, predicates, true); + } + + /** + * Reserved for the parser: it is the only place where the form of a step is known for certain. + */ + XPathStep(String stepText, List predicates, boolean nodeTest) { this.stepText = StringUtils.strip(stepText); this.predicates = predicates; + this.nodeTest = nodeTest; } public String getStepText() { @@ -170,4 +190,21 @@ public int compareTo(XPathStep other) { public boolean isVariableStep() { return stepText.startsWith("$"); } + + /** + * Whether this step only moves about, without naming anything: the current node or the parent + * node. Any predicate it carries still describes the node it arrives at. + */ + public boolean isNavigationStep() { + return ".".equals(this.stepText) || "..".equals(this.stepText); + } + + /** + * Whether this step names the nodes to look for, which is what an axis has to be followed by. + * A parent step, an attribute, an axis of the step's own, a variable, a function call, a literal + * and a parenthesised expression are all steps, but none of them can be written after an axis. + */ + public boolean isNodeTest() { + return this.nodeTest; + } } diff --git a/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java b/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java index 086f9d1..d612a80 100644 --- a/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java +++ b/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java @@ -206,6 +206,42 @@ void testAddAxis() { assertEquals("descendant::b/c", XPathProcessor.addAxis("descendant", "../../b/c")); } + @Test + void testAddAxis_MustPreserveThePredicates() { + assertEquals("preceding::b[x = 'y']/c", + XPathProcessor.addAxis("preceding", "b[x = 'y']/c")); + assertEquals("preceding::b/c[x = 'y']", + XPathProcessor.addAxis("preceding", "b/c[x = 'y']")); + assertEquals("preceding::b[e][f]/c[g]", + XPathProcessor.addAxis("preceding", "b[e][f]/c[g]")); + assertEquals("descendant::b[x = 'y']/c", + XPathProcessor.addAxis("descendant", "../../b[x = 'y']/c")); + } + + @Test + void testAddAxis_MustNameSomethingAfterTheAxis() { + assertEquals("preceding::*", XPathProcessor.addAxis("preceding", ".")); + assertEquals("preceding::*", XPathProcessor.addAxis("preceding", "..")); + assertEquals("preceding::*", XPathProcessor.addAxis("preceding", "../..")); + assertEquals("preceding::b", XPathProcessor.addAxis("preceding", "./b")); + assertEquals("preceding::*/.[x]/b", XPathProcessor.addAxis("preceding", ".[x]/b")); + assertEquals("preceding::*/..[x]/b", XPathProcessor.addAxis("preceding", "..[x]/b")); + assertEquals("preceding::*/@x", XPathProcessor.addAxis("preceding", "@x")); + assertEquals("preceding::*/child::b/c", XPathProcessor.addAxis("preceding", "child::b/c")); + assertEquals("preceding::*/doc('x')/b", XPathProcessor.addAxis("preceding", "doc('x')/b")); + assertEquals("preceding::*/id('x')/b", XPathProcessor.addAxis("preceding", "id('x')/b")); + assertEquals("preceding::*/(a | b)/c", XPathProcessor.addAxis("preceding", "(a | b)/c")); + assertEquals("preceding::*/$var/b", XPathProcessor.addAxis("preceding", "$var/b")); + assertEquals("preceding::text()/b", XPathProcessor.addAxis("preceding", "text()/b")); + } + + @Test + void testAddAxis_MustReadAnAbsolutePathFromTheContext() { + assertEquals("preceding::a/b", XPathProcessor.addAxis("preceding", "/a/b")); + assertEquals("preceding::a/b", XPathProcessor.addAxis("preceding", "//a/b")); + assertEquals("preceding::a[x]/b", XPathProcessor.addAxis("preceding", "/a[x]/b")); + } + @Test void testJoin() { assertEquals("a/b/c/d", XPathProcessor.join("a/b", "c/d")); diff --git a/src/test/java/eu/europa/ted/eforms/xpath/XPathStepTest.java b/src/test/java/eu/europa/ted/eforms/xpath/XPathStepTest.java index 28f17d6..499475e 100644 --- a/src/test/java/eu/europa/ted/eforms/xpath/XPathStepTest.java +++ b/src/test/java/eu/europa/ted/eforms/xpath/XPathStepTest.java @@ -86,6 +86,31 @@ void testComparison_AddPredicates() { assertTrue(b.isSameAsOrNarrowerThan(a)); } + @Test + void testIsNodeTest() { + assertTrue(firstStepOf("foo/bar").isNodeTest()); + assertTrue(firstStepOf("ns:foo").isNodeTest()); + assertTrue(firstStepOf("*").isNodeTest()); + assertTrue(firstStepOf("text()").isNodeTest()); + assertTrue(firstStepOf("node()").isNodeTest()); + assertTrue(firstStepOf("foo[x = 1]").isNodeTest()); + + assertFalse(firstStepOf(".").isNodeTest()); + assertFalse(firstStepOf("..").isNodeTest()); + assertFalse(firstStepOf(".[x = 1]").isNodeTest()); + assertFalse(firstStepOf("@foo").isNodeTest()); + assertFalse(firstStepOf("$var").isNodeTest()); + assertFalse(firstStepOf("child::foo").isNodeTest()); + assertFalse(firstStepOf("parent::foo").isNodeTest()); + assertFalse(firstStepOf("doc('x')/foo").isNodeTest()); + assertFalse(firstStepOf("id('x')").isNodeTest()); + assertFalse(firstStepOf("(a | b)/c").isNodeTest()); + } + + private XPathStep firstStepOf(final String path) { + return XPathProcessor.parse(path).getSteps().get(0); + } + private XPathStep buildStep(String elt, String... predicates) { return new XPathStep(elt, Arrays.asList(predicates)); } From 527a1e57899a68898dc5b80f8e2a1acac5587713 Mon Sep 17 00:00:00 2001 From: Ioannis Rosuochatzakis Date: Sat, 29 Aug 2026 14:28:37 +0200 Subject: [PATCH 2/2] TEDEFO-5150: Classify steps from the parse tree instead of their text --- .../ted/eforms/xpath/XPathListenerImpl.java | 104 ++++++++++---- .../ted/eforms/xpath/XPathProcessor.java | 32 ++--- .../eu/europa/ted/eforms/xpath/XPathStep.java | 134 +++++++++++++----- .../ted/eforms/xpath/XPathProcessorTest.java | 37 +++-- .../ted/eforms/xpath/XPathStepTest.java | 83 ++++++++--- 5 files changed, 283 insertions(+), 107 deletions(-) diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java index 19e2415..83ea435 100644 --- a/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java +++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathListenerImpl.java @@ -18,6 +18,11 @@ import eu.europa.ted.eforms.xpath.XPath20Parser.AxisstepContext; import eu.europa.ted.eforms.xpath.XPath20Parser.FilterexprContext; import eu.europa.ted.eforms.xpath.XPath20Parser.PredicateContext; +import eu.europa.ted.eforms.xpath.XPath20Parser.PredicatelistContext; +import eu.europa.ted.eforms.xpath.XPath20Parser.ForwardaxisContext; +import eu.europa.ted.eforms.xpath.XPath20Parser.ForwardstepContext; +import eu.europa.ted.eforms.xpath.XPath20Parser.NodetestContext; +import eu.europa.ted.eforms.xpath.XPath20Parser.ReversestepContext; class XPathListenerImpl extends XPath20BaseListener { private XPathInfo xpathInfo; @@ -44,10 +49,7 @@ public XPathInfo parse(String xpathInput) { final ParseTreeWalker walker = new ParseTreeWalker(); walker.walk(this, tree); - steps.stream().forEach(s -> { - XPathStep step = new XPathStep(s.stepText, s.predicates, s.nodeTest); - xpathInfo.addStep(step); - }); + steps.stream().forEach(s -> xpathInfo.addStep(s.step)); if (!xpathInfo.isAttribute()) { // The XPath does not point to an attribute, so it is the path to the last element @@ -164,44 +166,88 @@ private Boolean inPredicateMode() { } /** - * Whether the step is written as a bare node test, which the grammar spells out as - * {@code abbrevforwardstep : AT? nodetest}. That is the only form an axis can be followed by: a - * reverse step, a step carrying an axis of its own and an attribute are all steps, but none of - * them can be written after {@code axis::}. + * The step an axis step is written as, told apart by whether what it looks for could be looked + * for along another axis. A step is read the same way however it is spelled: {@code b} is + * {@code child::b}, {@code @x} is {@code attribute::x} and {@code ..} is {@code parent::node()}. */ - private static boolean isBareNodeTest(final AxisstepContext ctx) { - return ctx.forwardstep() != null && ctx.forwardstep().abbrevforwardstep() != null - && ctx.forwardstep().abbrevforwardstep().AT() == null; + private XPathStep readAxisStep(final AxisstepContext ctx, final List predicates) { + final ForwardstepContext forward = ctx.forwardstep(); + if (forward != null) { + final AbbrevforwardstepContext abbreviated = forward.abbrevforwardstep(); + if (abbreviated != null) { + return abbreviated.AT() != null + ? XPathStep.opaque(getInputText(forward), predicates) + : XPathStep.retargetable(getInputText(forward), + getInputText(abbreviated.nodetest()), predicates); + } + + final ForwardaxisContext axis = forward.forwardaxis(); + if (axis.KW_ATTRIBUTE() != null || axis.KW_NAMESPACE() != null) { + // An attribute and a namespace are found on the axis leading to them and nowhere else, so + // a node test naming one means nothing along another axis. + return XPathStep.opaque(getInputText(forward), predicates); + } + if (axis.KW_SELF() != null && namesAnyNode(forward.nodetest())) { + return XPathStep.navigation(getInputText(forward), predicates); + } + return XPathStep.retargetable(getInputText(forward), getInputText(forward.nodetest()), + predicates); + } + + final ReversestepContext reverse = ctx.reversestep(); + if (reverse.reverseaxis() == null) { + return XPathStep.navigation(getInputText(reverse), predicates); + } + if (reverse.reverseaxis().KW_PARENT() != null && namesAnyNode(reverse.nodetest())) { + return XPathStep.navigation(getInputText(reverse), predicates); + } + return XPathStep.retargetable(getInputText(reverse), getInputText(reverse.nodetest()), + predicates); + } + + /** + * The step a filter expression is written as. The context item is the one of them that only moves + * about; the rest are evaluated for the nodes they return and stay where they are. + */ + private XPathStep readFilterStep(final FilterexprContext ctx, final List predicates) { + if (ctx.primaryexpr().contextitemexpr() != null) { + return XPathStep.navigation(getInputText(ctx.primaryexpr()), predicates); + } + return XPathStep.opaque(getInputText(ctx.primaryexpr()), predicates); + } + + /** + * Whether the node test takes any node at all, which is what the steps that only move about look + * for. It is the {@code node()} of {@code self::node()} and {@code parent::node()}, written short + * as {@code .} and {@code ..}. + */ + private static boolean namesAnyNode(final NodetestContext ctx) { + return ctx.kindtest() != null && ctx.kindtest().anykindtest() != null; } + private static List predicatesOf(final PredicatelistContext ctx, + final Function getInputText) { + return ctx.predicate().stream().map(getInputText).collect(Collectors.toList()); + } + + private class StepInfo { - String stepText; - List predicates; - boolean nodeTest; + XPathStep step; int a; int b; private StepInfo(AxisstepContext ctx, Function getInputText) { - this(ctx.reversestep() != null ? getInputText.apply(ctx.reversestep()) : getInputText.apply(ctx.forwardstep()), - ctx.predicatelist().predicate().stream().map(getInputText).collect(Collectors.toList()), - ctx.getSourceInterval(), isBareNodeTest(ctx)); + this(readAxisStep(ctx, predicatesOf(ctx.predicatelist(), getInputText)), + ctx.getSourceInterval()); } - /** - * A filter expression is a literal, a variable, a parenthesised expression, the context item or - * a function call. None of them names nodes the way an axis needs. - */ private StepInfo(FilterexprContext ctx, Function getInputText) { - this(getInputText.apply(ctx.primaryexpr()), - ctx.predicatelist().predicate().stream().map(getInputText).collect(Collectors.toList()), - ctx.getSourceInterval(), false); + this(readFilterStep(ctx, predicatesOf(ctx.predicatelist(), getInputText)), + ctx.getSourceInterval()); } - private StepInfo(String stepText, List predicates, Interval interval, - boolean nodeTest) { - this.stepText = stepText; - this.predicates = predicates; - this.nodeTest = nodeTest; + private StepInfo(XPathStep step, Interval interval) { + this.step = step; this.a = interval.a; this.b = interval.b; } diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java index 0147b44..22dade7 100644 --- a/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java +++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathProcessor.java @@ -2,7 +2,6 @@ import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Queue; @@ -17,33 +16,34 @@ public static XPathInfo parse(String xpathInput) { } /** - * Rewrites a path so that its first step looks along the given axis instead of along the child - * axis. The path is read as relative to the context the axis is applied from; an absolute path - * cannot keep its anchor, because an axis cannot be followed by a separator, so it is read the - * same way. + * Rewrites a path so that it looks along the given axis instead of along the one it was written + * for. * *

- * Where the path gives the axis nothing to name, a wildcard stands in for it, so that a valid - * path is always returned for a valid path given. + * This serves the axis that can be written on an EFX-1 field reference, and nothing more. The + * axis is expected to be one that XPath knows, and the path to be relative to the context the + * axis is applied from; an absolute path cannot keep its anchor, because an axis cannot be + * followed by a separator, so it is read the same way. It is not a general way of rewriting + * XPath. */ public static String addAxis(final String axis, final String path) { final LinkedList steps = new LinkedList<>(parse(path).getSteps()); - // An axis searches the document from the context node, so moving away from it beforehand makes - // no difference to what is found, and those steps are dropped. A step that carries a predicate - // is kept: the predicate describes the node it arrives at, which is part of what we look for. - while (!steps.isEmpty() && steps.getFirst().isNavigationStep() + // Moving about before the axis makes no difference to what it finds, since it searches from the + // context node wherever the path would have gone first. Such steps are dropped, except for the + // one the axis is put on, and except where a predicate says which node was arrived at. + while (steps.size() > 1 && steps.getFirst().isNavigationStep() && steps.getFirst().getPredicates().isEmpty()) { steps.removeFirst(); } - // An axis has to be followed by the nodes to look for. Where the path does not begin by naming - // any, a wildcard names them instead and the path follows it unchanged. - if (steps.isEmpty() || !steps.getFirst().isNodeTest()) { - steps.addFirst(new XPathStep("*", Collections.emptyList())); + if (steps.isEmpty()) { + return XPathStep.anyNodeOn(axis).toString(); } - return axis + "::" + steps.stream().map(s -> s.toString()).collect(Collectors.joining("/")); + steps.addAll(0, steps.removeFirst().onAxis(axis)); + + return steps.stream().map(s -> s.toString()).collect(Collectors.joining("/")); } public static String join(final String first, final String second) { diff --git a/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java b/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java index ff00ad9..6a02b1d 100644 --- a/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java +++ b/src/main/java/eu/europa/ted/eforms/xpath/XPathStep.java @@ -1,6 +1,7 @@ package eu.europa.ted.eforms.xpath; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; @@ -8,33 +9,119 @@ import org.apache.commons.lang3.StringUtils; +/** + * One step of a path. + * + *

+ * A step read from a path is known well enough to say whether it can be looked for along another + * axis, which is all {@link XPathProcessor#addAxis} needs of it. A step built from its text alone + * says nothing of the sort and is taken at its word. + * + *

+ * How a step was read is not part of what it is worth: two steps written the same way, carrying the + * same predicates, are the same step. + */ public class XPathStep implements Comparable { + /** The node test that matches any node, which the steps that only move about look for. */ + private static final String ANY_NODE = "node()"; + + /** + * As much as is needed to know whether a step can be looked for along a different axis. This is + * not a reading of XPath's own grammar, which distinguishes far more than this: an explicit + * {@code child::b} and a plain {@code b} are told apart there and are the same thing here. + */ + private enum StepKind { + /** The step names something that can be looked for along another axis. */ + RETARGETABLE, + + /** The step only moves about, naming nothing: the current node, or the parent node. */ + NAVIGATION, + + /** + * Anything else. An attribute, a namespace and an expression all name something that is only + * found where it already is, and a step built from text alone is not known at all. All of them + * stay where they are. + */ + OPAQUE + } + private final String stepText; private final List predicates; + private final StepKind kind; + + /** What the step looks for, where it looks for anything. */ + private final String nodeTest; /** - * Whether the step was written as a bare node test, as the parser read it from the grammar. It - * follows from the step itself rather than being a fact of its own, so it takes no part in - * equality or ordering. + * Builds a step from the text it is written as. Steps read from a path are built by the parser, + * which knows more about them than their text says. */ - private final boolean nodeTest; + public XPathStep(String stepText, List predicates) { + this(stepText, predicates, StepKind.OPAQUE, null); + } + + private XPathStep(final String stepText, final List predicates, final StepKind kind, + final String nodeTest) { + this.stepText = StringUtils.strip(stepText); + this.predicates = predicates == null ? Collections.emptyList() : predicates; + this.kind = kind; + this.nodeTest = nodeTest; + } /** - * Builds a step that names the nodes to look for, which is what a step written as an element name - * does. Steps read from a path are built by the parser instead, which tells the two apart from - * the grammar. + * A step that names what it looks for, which can therefore be looked for along another axis. The + * node test is what it looks for, apart from however the step happens to be written. */ - public XPathStep(String stepText, List predicates) { - this(stepText, predicates, true); + static XPathStep retargetable(final String stepText, final String nodeTest, + final List predicates) { + return new XPathStep(stepText, predicates, StepKind.RETARGETABLE, + StringUtils.strip(nodeTest)); } /** - * Reserved for the parser: it is the only place where the form of a step is known for certain. + * A step that only moves about: {@code .} or {@code ..}. It names nothing, so what it arrives at + * is any node at all, and any predicate it carries describes that node. */ - XPathStep(String stepText, List predicates, boolean nodeTest) { - this.stepText = StringUtils.strip(stepText); - this.predicates = predicates; - this.nodeTest = nodeTest; + static XPathStep navigation(final String stepText, final List predicates) { + return new XPathStep(stepText, predicates, StepKind.NAVIGATION, ANY_NODE); + } + + /** + * A step that has to be left where it is: an attribute, a namespace, or an expression evaluated + * for the nodes it returns. + */ + static XPathStep opaque(final String stepText, final List predicates) { + return new XPathStep(stepText, predicates, StepKind.OPAQUE, null); + } + + /** + * A step that walks the given axis and takes whatever it finds. + */ + static XPathStep anyNodeOn(final String axis) { + return retargetable(axis + "::" + ANY_NODE, ANY_NODE, Collections.emptyList()); + } + + /** + * The same step, looked for along the given axis instead. + * + *

+ * A step that names what it looks for is simply looked for elsewhere, and one step comes back. A + * step that has to stay where it is keeps its place behind a step that walks the axis, and two + * come back. + */ + List onAxis(final String axis) { + if (this.kind == StepKind.OPAQUE) { + return Arrays.asList(anyNodeOn(axis), this); + } + return Collections + .singletonList(retargetable(axis + "::" + this.nodeTest, this.nodeTest, this.predicates)); + } + + /** + * Whether the step only moves about, without naming anything to look for. + */ + boolean isNavigationStep() { + return this.kind == StepKind.NAVIGATION; } public String getStepText() { @@ -152,7 +239,7 @@ public boolean isTheSameAs(final XPathStep other) { * This method was renamed for clarity. It is marked as deprecated so that the * library interface does not change. It will be removed in the next major * version of the library. - * + * */ @Deprecated(since = "1.3.0", forRemoval = true) public boolean isSimilarTo(final XPathStep other) { @@ -190,21 +277,4 @@ public int compareTo(XPathStep other) { public boolean isVariableStep() { return stepText.startsWith("$"); } - - /** - * Whether this step only moves about, without naming anything: the current node or the parent - * node. Any predicate it carries still describes the node it arrives at. - */ - public boolean isNavigationStep() { - return ".".equals(this.stepText) || "..".equals(this.stepText); - } - - /** - * Whether this step names the nodes to look for, which is what an axis has to be followed by. - * A parent step, an attribute, an axis of the step's own, a variable, a function call, a literal - * and a parenthesised expression are all steps, but none of them can be written after an axis. - */ - public boolean isNodeTest() { - return this.nodeTest; - } } diff --git a/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java b/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java index d612a80..034e29d 100644 --- a/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java +++ b/src/test/java/eu/europa/ted/eforms/xpath/XPathProcessorTest.java @@ -219,22 +219,35 @@ void testAddAxis_MustPreserveThePredicates() { } @Test - void testAddAxis_MustNameSomethingAfterTheAxis() { - assertEquals("preceding::*", XPathProcessor.addAxis("preceding", ".")); - assertEquals("preceding::*", XPathProcessor.addAxis("preceding", "..")); - assertEquals("preceding::*", XPathProcessor.addAxis("preceding", "../..")); + void testAddAxis_MustAimTheStepTheAxisLandsOn() { + assertEquals("preceding::node()", XPathProcessor.addAxis("preceding", ".")); + assertEquals("preceding::node()", XPathProcessor.addAxis("preceding", "..")); + assertEquals("preceding::node()", XPathProcessor.addAxis("preceding", "../..")); assertEquals("preceding::b", XPathProcessor.addAxis("preceding", "./b")); - assertEquals("preceding::*/.[x]/b", XPathProcessor.addAxis("preceding", ".[x]/b")); - assertEquals("preceding::*/..[x]/b", XPathProcessor.addAxis("preceding", "..[x]/b")); - assertEquals("preceding::*/@x", XPathProcessor.addAxis("preceding", "@x")); - assertEquals("preceding::*/child::b/c", XPathProcessor.addAxis("preceding", "child::b/c")); - assertEquals("preceding::*/doc('x')/b", XPathProcessor.addAxis("preceding", "doc('x')/b")); - assertEquals("preceding::*/id('x')/b", XPathProcessor.addAxis("preceding", "id('x')/b")); - assertEquals("preceding::*/(a | b)/c", XPathProcessor.addAxis("preceding", "(a | b)/c")); - assertEquals("preceding::*/$var/b", XPathProcessor.addAxis("preceding", "$var/b")); + assertEquals("preceding::node()[x]/b", XPathProcessor.addAxis("preceding", ".[x]/b")); + assertEquals("preceding::node()[x]/b", XPathProcessor.addAxis("preceding", "..[x]/b")); + assertEquals("preceding::b/c", XPathProcessor.addAxis("preceding", "child::b/c")); + assertEquals("preceding::b/c", XPathProcessor.addAxis("preceding", "following::b/c")); assertEquals("preceding::text()/b", XPathProcessor.addAxis("preceding", "text()/b")); } + @Test + void testAddAxis_MustKeepAStepThatCannotBeAimed() { + assertEquals("preceding::node()/@x", XPathProcessor.addAxis("preceding", "@x")); + assertEquals("preceding::node()/$var/b", XPathProcessor.addAxis("preceding", "$var/b")); + assertEquals("preceding::node()/doc('x')/b", XPathProcessor.addAxis("preceding", "doc('x')/b")); + assertEquals("preceding::node()/id('x')/b", XPathProcessor.addAxis("preceding", "id('x')/b")); + assertEquals("preceding::node()/(a | b)/c", XPathProcessor.addAxis("preceding", "(a | b)/c")); + assertEquals("preceding::node()/namespace::x", + XPathProcessor.addAxis("preceding", "namespace::x")); + } + + @Test + void testJoin_MustNotRewriteTheStepsItWasGiven() { + assertEquals("child::a/attribute::x", XPathProcessor.join("child::a", "attribute::x")); + assertEquals("self::node()/b", XPathProcessor.join("self::node()", "b")); + } + @Test void testAddAxis_MustReadAnAbsolutePathFromTheContext() { assertEquals("preceding::a/b", XPathProcessor.addAxis("preceding", "/a/b")); diff --git a/src/test/java/eu/europa/ted/eforms/xpath/XPathStepTest.java b/src/test/java/eu/europa/ted/eforms/xpath/XPathStepTest.java index 499475e..019d93e 100644 --- a/src/test/java/eu/europa/ted/eforms/xpath/XPathStepTest.java +++ b/src/test/java/eu/europa/ted/eforms/xpath/XPathStepTest.java @@ -1,5 +1,6 @@ package eu.europa.ted.eforms.xpath; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -87,24 +88,70 @@ void testComparison_AddPredicates() { } @Test - void testIsNodeTest() { - assertTrue(firstStepOf("foo/bar").isNodeTest()); - assertTrue(firstStepOf("ns:foo").isNodeTest()); - assertTrue(firstStepOf("*").isNodeTest()); - assertTrue(firstStepOf("text()").isNodeTest()); - assertTrue(firstStepOf("node()").isNodeTest()); - assertTrue(firstStepOf("foo[x = 1]").isNodeTest()); - - assertFalse(firstStepOf(".").isNodeTest()); - assertFalse(firstStepOf("..").isNodeTest()); - assertFalse(firstStepOf(".[x = 1]").isNodeTest()); - assertFalse(firstStepOf("@foo").isNodeTest()); - assertFalse(firstStepOf("$var").isNodeTest()); - assertFalse(firstStepOf("child::foo").isNodeTest()); - assertFalse(firstStepOf("parent::foo").isNodeTest()); - assertFalse(firstStepOf("doc('x')/foo").isNodeTest()); - assertFalse(firstStepOf("id('x')").isNodeTest()); - assertFalse(firstStepOf("(a | b)/c").isNodeTest()); + void testSpellingsAreReadAsStepsOnAnAxis() { + // The spelling is not rewritten, but the step knows the axis behind it, which is what lets it + // be looked for along another one. + assertEquals("preceding::b/c", XPathProcessor.addAxis("preceding", "b/c")); + assertEquals("preceding::b/c", XPathProcessor.addAxis("preceding", "child::b/c")); + assertEquals("preceding::text()/b", XPathProcessor.addAxis("preceding", "text()/b")); + assertEquals("preceding::node()", XPathProcessor.addAxis("preceding", "..")); + assertEquals("preceding::node()", XPathProcessor.addAxis("preceding", ".")); + assertEquals("preceding::node()", XPathProcessor.addAxis("preceding", "self::node()")); + } + + @Test + void testAStepIsReadTheSameWayHoweverItIsSpelled() { + // Each pair says the same thing, the one spelled out and the other short, so each pair has to + // come out the same. + assertEquals(XPathProcessor.addAxis("preceding", "./b"), + XPathProcessor.addAxis("preceding", "self::node()/b")); + assertEquals(XPathProcessor.addAxis("preceding", "../b"), + XPathProcessor.addAxis("preceding", "parent::node()/b")); + assertEquals(XPathProcessor.addAxis("preceding", ".[x]/b"), + XPathProcessor.addAxis("preceding", "self::node()[x]/b")); + assertEquals(XPathProcessor.addAxis("preceding", "..[x]/b"), + XPathProcessor.addAxis("preceding", "parent::node()[x]/b")); + assertEquals(XPathProcessor.addAxis("preceding", "b/c"), + XPathProcessor.addAxis("preceding", "child::b/c")); + + // Naming something is not moving about, even on the same axes. + assertEquals("preceding::b/c", XPathProcessor.addAxis("preceding", "self::b/c")); + assertEquals("preceding::b/c", XPathProcessor.addAxis("preceding", "parent::b/c")); + assertEquals("preceding::text()/b", XPathProcessor.addAxis("preceding", "self::text()/b")); + } + + @Test + void testExpressionsAreNotLookedForAlongAnAxis() { + assertEquals("preceding::node()/$var/b", XPathProcessor.addAxis("preceding", "$var/b")); + assertEquals("preceding::node()/doc('x')/b", XPathProcessor.addAxis("preceding", "doc('x')/b")); + assertEquals("preceding::node()/(a | b)/c", XPathProcessor.addAxis("preceding", "(a | b)/c")); + } + + @Test + void testAStepKeepsTheSpellingItWasReadFrom() { + assertEquals("b", firstStepOf("b/c").getStepText()); + assertEquals("@x", firstStepOf("@x").getStepText()); + assertEquals("..", firstStepOf("..").getStepText()); + assertEquals(".", firstStepOf(".").getStepText()); + assertEquals("preceding::b", firstStepOf("preceding::b/c").getStepText()); + + // A spelled-out step is not rewritten, even where a shorter spelling says the same thing. + assertEquals("child::b", firstStepOf("child::b/c").getStepText()); + assertEquals("attribute::x", firstStepOf("attribute::x").getStepText()); + assertEquals("self::node()", firstStepOf("self::node()").getStepText()); + assertEquals("parent::node()", firstStepOf("parent::node()").getStepText()); + } + + @Test + void testAStepReadFromAPathIsTheSameAsOneBuiltFromItsText() { + final XPathStep read = firstStepOf("a[x = 1]"); + final XPathStep built = new XPathStep("a", Arrays.asList("[x = 1]")); + + assertEquals(built, read); + assertEquals(read, built); + assertEquals(built.hashCode(), read.hashCode()); + assertEquals(0, read.compareTo(built)); + assertTrue(read.isTheSameAs(built)); } private XPathStep firstStepOf(final String path) {