This is an automated email from the ASF dual-hosted git repository. desruisseaux pushed a commit to branch geoapi-4.0 in repository https://gitbox.apache.org/repos/asf/sis.git
The following commit(s) were added to refs/heads/geoapi-4.0 by this push: new 078f89d40d Add basic support for Braced URI Literal in the XPath expression given to `FilterFactory.property(String)`. 078f89d40d is described below commit 078f89d40dc59b0f7877fdc434eb743a1c953a31 Author: Martin Desruisseaux <martin.desruisse...@geomatys.com> AuthorDate: Thu Nov 16 15:00:58 2023 +0100 Add basic support for Braced URI Literal in the XPath expression given to `FilterFactory.property(String)`. --- .../org/apache/sis/filter/AssociationValue.java | 10 +- .../apache/sis/filter/DefaultFilterFactory.java | 14 +- .../main/org/apache/sis/filter/PropertyValue.java | 34 ++--- .../main/org/apache/sis/filter/internal/XPath.java | 157 ++++++++++++++++----- .../main/org/apache/sis/filter/package-info.java | 2 +- .../test/org/apache/sis/filter/XPathTest.java | 40 +++++- .../main/org/apache/sis/util/iso/Names.java | 2 + .../main/org/apache/sis/storage/FeatureQuery.java | 2 +- .../org/apache/sis/cql/ExpressionReadingTest.java | 4 +- 9 files changed, 197 insertions(+), 68 deletions(-) diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/AssociationValue.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/AssociationValue.java index ffd768a4f5..b4fd17293b 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/AssociationValue.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/AssociationValue.java @@ -21,10 +21,10 @@ import java.util.Set; import java.util.List; import java.util.Collection; import java.util.Optional; -import java.util.StringJoiner; import org.apache.sis.feature.Features; import org.apache.sis.feature.builder.FeatureTypeBuilder; import org.apache.sis.feature.builder.PropertyTypeBuilder; +import org.apache.sis.filter.internal.XPath; import org.apache.sis.math.FunctionProperty; // Specific to the geoapi-3.1 and geoapi-4.0 branches: @@ -124,9 +124,11 @@ final class AssociationValue<V> extends LeafExpression<Feature, V> */ @Override public final String getXPath() { - final StringJoiner sb = new StringJoiner("/", accessor.isVirtual ? PropertyValue.VIRTUAL_PREFIX : "", ""); - for (final String p : path) sb.add(p); - return sb.add(accessor.name).toString(); + String s = new XPath(path, accessor.name).toString(); + if (accessor.isVirtual) { + s = PropertyValue.VIRTUAL_PREFIX.concat(s); + } + return s; } /** diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/DefaultFilterFactory.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/DefaultFilterFactory.java index eed4a30fe0..4b8a162e81 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/DefaultFilterFactory.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/DefaultFilterFactory.java @@ -51,7 +51,7 @@ import org.apache.sis.util.internal.AbstractMap; * * @author Johann Sorel (Geomatys) * @author Martin Desruisseaux (Geomatys) - * @version 1.4 + * @version 1.5 * * @param <R> the type of resources (e.g. {@link org.opengis.feature.Feature}) to use as inputs. * @param <G> base class of geometry objects. The implementation-neutral type is GeoAPI {@link Geometry}, @@ -222,6 +222,18 @@ public abstract class DefaultFilterFactory<R,G,T> extends AbstractFactory implem * then {@code type} can be <code>{@linkplain Number}.class</code>. If property values can be of any type with no * conversion desired, then {@code type} should be {@code Object.class}.</p> * + * <h4>Supported XPath syntax</h4> + * If the given {@code xpath} contains the "/" character, then all path components before the last one + * are interpreted as associations to follow. For example if the XPath is {@code "client/name"}, then + * the {@code ValueReference} applied on feature <var>F</var> will first search for an association + * named {@code "client"} to feature <var>C</var>, then search for a property named {@code "name"} + * in feature <var>C</var>. + * + * <p>The given {@code xpath} may contain scoped names. + * For example {@code "foo:client"} is the name {@code "client"} in scope {@code "foo"}. + * If the scope is an URL, then it needs to be enclosed inside {@code "Q{…}"}. + * Example: {@code "Q{http://www.foo.com/bar}client"}.</p> + * * @param <V> the type of the values to be fetched (compile-time value of {@code type}). * @param xpath the path to the property whose value will be returned by the {@code apply(R)} method. * @param type the type of the values to be fetched (run-time value of {@code <V>}). diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/PropertyValue.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/PropertyValue.java index a3d9160f03..bd930ebdbe 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/PropertyValue.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/PropertyValue.java @@ -97,38 +97,32 @@ abstract class PropertyValue<V> extends LeafExpression<Feature,V> * @throws IllegalArgumentException if the given XPath is not supported. */ @SuppressWarnings("unchecked") - static <V> ValueReference<Feature,V> create(String xpath, final Class<V> type) { + static <V> ValueReference<Feature,V> create(final String xpath, final Class<V> type) { + final var parsed = new XPath(xpath); + List<String> path = parsed.path; boolean isVirtual = false; - List<String> path = XPath.split(xpath); -split: if (path != null) { + if (parsed.isAbsolute) { /* * If the XPath is like "/∗/property" where the root "/" is the feature instance, * we interpret that as meaning "property of a feature of any type", which means * to relax the restriction about the set of allowed properties. */ - final String head = path.get(0); // List and items in the list are guaranteed non-empty. - isVirtual = head.equals("/*"); - if (isVirtual || head.charAt(0) != XPath.SEPARATOR) { - final int offset = isVirtual ? 1 : 0; // Skip the "/*/" component at index 0. - final int last = path.size() - 1; - if (last >= offset) { - xpath = path.get(last); - path = path.subList(offset, last); - break split; // Accept the path as valid. - } + isVirtual = (path != null) && path.get(0).equals("*"); + if (!isVirtual) { + throw new IllegalArgumentException(Errors.format(Errors.Keys.UnsupportedXPath_1, xpath)); + } + path.remove(0); + if (path.isEmpty()) { + path = null; } - throw new IllegalArgumentException(Errors.format(Errors.Keys.UnsupportedXPath_1, xpath)); } - /* - * At this point, `xpath` is the tip of the path (i.e. prefixes have been removed). - */ final PropertyValue<V> tip; if (type != Object.class) { - tip = new Converted<>(type, xpath, isVirtual); + tip = new Converted<>(type, parsed.tip, isVirtual); } else { - tip = (PropertyValue<V>) new AsObject(xpath, isVirtual); + tip = (PropertyValue<V>) new AsObject(parsed.tip, isVirtual); } - return (path == null || path.isEmpty()) ? tip : new AssociationValue<>(path, tip); + return (path != null) ? new AssociationValue<>(path, tip) : tip; } /** diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/internal/XPath.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/internal/XPath.java index 3f13b475ae..a267b691fa 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/internal/XPath.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/internal/XPath.java @@ -17,8 +17,8 @@ package org.apache.sis.filter.internal; import java.util.List; +import java.util.Arrays; import java.util.ArrayList; -import org.apache.sis.util.Static; import org.apache.sis.util.resources.Errors; import static org.apache.sis.util.CharSequences.*; @@ -30,7 +30,7 @@ import static org.apache.sis.util.CharSequences.*; * * @author Martin Desruisseaux (Geomatys) */ -public final class XPath extends Static { +public final class XPath { /** * The separator between path components. * Should not be used for URL or Unix name separator, even if the character is the same. @@ -39,47 +39,138 @@ public final class XPath extends Static { public static final char SEPARATOR = '/'; /** - * Do not allow instantiation of this class. + * The prefix for names qualified by their URI instead of prefix. + * Example: {@code "Q{http://example.com/foo/bar}feature/property"}. + * + * @see <a href="https://www.w3.org/TR/xpath-31/#doc-xpath31-URIQualifiedName">XPath 3.1 qualified name</a> */ - private XPath() { - } + private static final char BRACED_URI_PREFIX = 'Q'; /** - * Splits the given URL around the {@code '/'} separator, or returns {@code null} if there is no separator. - * By convention if the URL is absolute, then the leading {@code '/'} character is kept in the first element. - * For example, {@code "/∗/property"} is splitted as two elements: {@code "/∗"} and {@code "property"}. - * - * <p>This method trims the whitespaces of components except the last one (the tip), - * for consistency with the case where this method returns {@code null}.</p> + * The characters used as delimiters for braced URI literals. + * The open bracket should be prefixed by {@value #BRACED_URI_PREFIX}, + * but this is optional in this implementation. + */ + private static final char OPEN = '{', CLOSE = '}'; + + /** + * The components of the XPath before the tip, or {@code null} if none. + * This list, if non-null, contains at least one element but not the {@linkplain #tip}. + */ + public List<String> path; + + /** + * The tip of the XPath. + * This is the part after the last occurrence of {@value #SEPARATOR}, + * unless that occurrence was inside curly brackets for qualified name. + */ + public String tip; + + /** + * Whether the XPath has a leading {@value #SEPARATOR} character. + */ + public boolean isAbsolute; + + /** + * Splits the given XPath around the {@code '/'} separator, except for the part between curly brackets. + * If a leading {@code '/'} character is present, it is removed and {@link #isAbsolute} is set to true. + * This method trims the whitespaces of all components. * - * @param xpath the URL to split. - * @return the splitted URL with the heading separator kept in the first element, or {@code null} - * if there is no separator. If non-null, the list always contains at least one element. + * @param xpath the XPath to split. * @throws IllegalArgumentException if the XPath contains at least one empty component. */ - public static List<String> split(final String xpath) { - int next = xpath.indexOf(SEPARATOR); - if (next < 0) { - return null; + public XPath(final String xpath) { + /* + * Check whether the XPath is absolute. + * This is identified by a leading "/". + */ + int length = xpath.length(); + int start = skipLeadingWhitespaces(xpath, 0, length); + if (start >= length) { + throw new IllegalArgumentException(Errors.format(Errors.Keys.EmptyArgument_1, "xpath")); } - final List<String> components = new ArrayList<>(4); - int start = skipLeadingWhitespaces(xpath, 0, next); - if (start < next) { - // No leading '/' (the characters before it are a path element, added below). - components.add(xpath.substring(start, skipTrailingWhitespaces(xpath, start, next))); - start = ++next; - } else { - // Keep the `start` position on the leading '/'. - next++; + if (xpath.charAt(start) == SEPARATOR) { + start = skipLeadingWhitespaces(xpath, start+1, length); + isAbsolute = true; } - while ((next = xpath.indexOf(SEPARATOR, next)) >= 0) { - components.add(trimWhitespaces(xpath, start, next).toString()); - start = ++next; + /* + * Check for braced URI literal, for example "Q{http://example.com}". + * The "Q" prefix is mandated by XPath 3.1 specification, but optional in this implementation. + * Any other prefix is considered an error, as the brackets may have another signification. + */ + int open = xpath.indexOf(OPEN, start); + if (open >= 0) { + final int before = skipLeadingWhitespaces(xpath, start, open); + if (before != open && (before != open-1 || xpath.charAt(before) != BRACED_URI_PREFIX)) { + throw new IllegalArgumentException(Errors.format(Errors.Keys.UnsupportedXPath_1, xpath.substring(before))); + } + final int close = xpath.indexOf(CLOSE, ++open); + if (close < 0) { + throw new IllegalArgumentException(Errors.format(Errors.Keys.MissingCharacterInElement_2, xpath.substring(before), CLOSE)); + } + final String part = trimWhitespaces(xpath, open, close).toString(); + if (part.indexOf(OPEN) >= 0) { + throw new IllegalArgumentException(Errors.format(Errors.Keys.IllegalCharacter_2, part, OPEN)); + } + path = new ArrayList<>(4); + path.add(part); + start = close + 1; } - components.add(xpath.substring(start)); // No whitespace trimming. - if (components.stream().anyMatch(String::isEmpty)) { + /* + * Add all components before the last "/" characters. + * The remaining is the tip, stored separately. + */ + int next; + while ((next = xpath.indexOf(SEPARATOR, start)) >= 0) { + if (path == null) { + path = new ArrayList<>(4); + } + path.add(trimWhitespaces(xpath, start, next).toString()); + start = next + 1; + } + tip = trimWhitespaces(xpath, start, length).toString(); + if (tip.isEmpty() || (path != null && path.stream().anyMatch(String::isEmpty))) { throw new IllegalArgumentException(Errors.format(Errors.Keys.UnsupportedXPath_1, xpath)); } - return components; + } + + /** + * Creates a XPath with the given path components. + * The components are assumed already parsed (no braced URI literals). + * + * @param path components of the XPath before the tip, or {@code null} if none. + * @param tip the last component of the XPath. + */ + public XPath(final String[] path, final String tip) { + if (path != null) { + this.path = Arrays.asList(path); + } + this.tip = tip; + } + + /** + * Rewrites the XPath from its components and the tip. + * + * @return the XPath. + */ + @Override + public String toString() { + if (!isAbsolute && path == null) { + return tip; + } + final var sb = new StringBuilder(40); + if (isAbsolute) sb.append(SEPARATOR); + if (path != null) { + final int size = path.size(); + for (int i=0; i<size; i++) { + final String part = path.get(i); + if (i == 0 && part.indexOf(SEPARATOR) >= 0) { + sb.append(BRACED_URI_PREFIX).append(OPEN).append(part).append(CLOSE); + } else { + sb.append(part).append(SEPARATOR); + } + } + } + return sb.append(tip).toString(); } } diff --git a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/package-info.java b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/package-info.java index 2fa11028a1..7d6c7630ea 100644 --- a/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/package-info.java +++ b/endorsed/src/org.apache.sis.feature/main/org/apache/sis/filter/package-info.java @@ -57,7 +57,7 @@ * * @author Johann Sorel (Geomatys) * @author Martin Desruisseaux (Geomatys) - * @version 1.4 + * @version 1.5 * * @since 1.1 */ diff --git a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/filter/XPathTest.java b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/filter/XPathTest.java index 31412c27b7..541157101a 100644 --- a/endorsed/src/org.apache.sis.feature/test/org/apache/sis/filter/XPathTest.java +++ b/endorsed/src/org.apache.sis.feature/test/org/apache/sis/filter/XPathTest.java @@ -22,7 +22,7 @@ import org.apache.sis.filter.internal.XPath; import org.junit.Test; import org.apache.sis.test.TestCase; -import static org.junit.Assert.*; +import static org.junit.jupiter.api.Assertions.*; /** @@ -38,13 +38,41 @@ public final class XPathTest extends TestCase { } /** - * Tests {@link XPath#split(String)}. + * Splits a x-path and verifies the result. + * + * @param xpath the x-path to parse. + * @param isAbsolute expected value if {@link XPath#isAbsolute}. + * @param path expected value if {@link XPath#path}. Can be null. + * @param tip expected value if {@link XPath#tip}. + */ + private static void split(final String xpath, final boolean isAbsolute, final String[] path, final String tip) { + final var p = new XPath(xpath); + assertEquals(isAbsolute, p.isAbsolute, "isAbsolute"); + assertArrayEquals(path, (p.path != null) ? p.path.toArray() : null, "path"); + assertEquals(tip, p.tip, "tip"); + assertEquals(xpath.replace(" ", ""), p.toString(), "toString()"); + } + + /** + * Tests {@link XPath#XPath(String)}. */ @Test public void testSplit() { - assertNull(XPath.split("property")); - assertArrayEquals(new String[] {"/property"}, XPath.split("/property").toArray()); - assertArrayEquals(new String[] {"Feature", "property", "child"}, XPath.split("Feature/property/child").toArray()); - assertArrayEquals(new String[] {"/Feature", "property"}, XPath.split("/Feature/property").toArray()); + split("property", false, null, "property"); + split("/property", true, null, "property"); + split("Feature/property/child", false, new String[] {"Feature", "property"}, "child"); + split("/Feature/property", true, new String[] {"Feature"}, "property"); + split(" Feature / property / child ", false, new String[] {"Feature", "property"}, "child"); + split(" / Feature / property ", true, new String[] {"Feature"}, "property"); + } + + /** + * Tests with a x-path containing an URL as the property namespace. + */ + @Test + public void testQualifiedName() { + split("Q{http://example.com/foo/bar}property", false, new String[] {"http://example.com/foo/bar"}, "property"); + split("Q{http://example.com/foo/bar}property/child", false, new String[] {"http://example.com/foo/bar", "property"}, "child"); + split("/Q{http://example.com/foo/bar}property", true, new String[] {"http://example.com/foo/bar"}, "property"); } } diff --git a/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/util/iso/Names.java b/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/util/iso/Names.java index 082413384d..e3c5c32071 100644 --- a/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/util/iso/Names.java +++ b/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/util/iso/Names.java @@ -27,6 +27,7 @@ import org.opengis.util.NameSpace; import org.opengis.util.NameFactory; import org.opengis.util.InternationalString; import org.apache.sis.util.Static; +import org.apache.sis.util.OptionalCandidate; import org.apache.sis.util.UnknownNameException; import static org.apache.sis.util.ArgumentChecks.ensureNonNull; @@ -416,6 +417,7 @@ public final class Names extends Static { * * @since 0.5 */ + @OptionalCandidate public static Class<?> toClass(final TypeName type) throws UnknownNameException { if (type == null) { return null; diff --git a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java index 172b87705a..52a6ec31d8 100644 --- a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java +++ b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/FeatureQuery.java @@ -779,7 +779,7 @@ public class FeatureQuery extends Query implements Cloneable, Serializable { name = valueType.getProperty(xpath).getName(); if (name == null || !names.add(name.toString())) { name = null; - xpath = xpath.substring(xpath.lastIndexOf(XPath.SEPARATOR) + 1); // Works also if '/' is not found. + xpath = new XPath(xpath).tip; if (!(xpath.isEmpty() || names.contains(xpath))) { text = xpath; } diff --git a/incubator/src/org.apache.sis.cql/test/org/apache/sis/cql/ExpressionReadingTest.java b/incubator/src/org.apache.sis.cql/test/org/apache/sis/cql/ExpressionReadingTest.java index e61ea86552..c92c1bb63f 100644 --- a/incubator/src/org.apache.sis.cql/test/org/apache/sis/cql/ExpressionReadingTest.java +++ b/incubator/src/org.apache.sis.cql/test/org/apache/sis/cql/ExpressionReadingTest.java @@ -75,11 +75,11 @@ public final class ExpressionReadingTest extends CQLTestCase { @Test public void testValueReference3() throws CQLException { - final String cql = "ùth{e_$uglY^_pr@perté"; + final String cql = "ùthe_$uglY^_pr@perté"; final Object obj = CQL.parseExpression(cql); assertTrue(obj instanceof ValueReference); final ValueReference expression = (ValueReference) obj; - assertEquals("ùth{e_$uglY^_pr@perté", expression.getXPath()); + assertEquals("ùthe_$uglY^_pr@perté", expression.getXPath()); } @Test