You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2023/05/03 17:19:35 UTC

[sis] 02/02: Retrofit `GroupAsPolylineOperation` together with all other feature operations. It required a generalization for working on attributes as well as associations.

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

commit 31126879d2c75a84d68146c449b9673eb4608f10
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Wed May 3 19:18:09 2023 +0200

    Retrofit `GroupAsPolylineOperation` together with all other feature operations.
    It required a generalization for working on attributes as well as associations.
---
 .../apache/sis/feature/DefaultAssociationRole.java |   8 +-
 .../org/apache/sis/feature/FeatureOperations.java  |  51 ++++-
 .../main/java/org/apache/sis/feature/Features.java |  39 +++-
 .../sis/feature/GroupAsPolylineOperation.java      | 248 +++++++++++++++++++++
 .../apache/sis/internal/feature/Geometries.java    |  33 ---
 .../sis/internal/feature/GeometryWrapper.java      |   6 +-
 .../apache/sis/internal/feature/esri/Wrapper.java  |   4 +-
 .../sis/internal/feature/j2d/PointWrapper.java     |   2 +-
 .../apache/sis/internal/feature/j2d/Wrapper.java   |   2 +-
 .../apache/sis/internal/feature/jts/Wrapper.java   |   2 +-
 .../sis/internal/feature/GeometriesTestCase.java   |   2 +-
 .../storage/gpx/GroupAsPolylineOperation.java      | 211 ------------------
 .../org/apache/sis/internal/storage/gpx/Types.java |  31 ++-
 13 files changed, 365 insertions(+), 274 deletions(-)

diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java b/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
index b7f0bbbbc3..690fbee171 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/DefaultAssociationRole.java
@@ -153,12 +153,12 @@ public class DefaultAssociationRole extends FieldType implements FeatureAssociat
      *     String    namespace = "My model";
      *     GenericName nameOfA = Names.createTypeName(namespace, ":", "Feature type A");
      *     GenericName nameOfB = Names.createTypeName(namespace, ":", "Feature type B");
-     *     FeatureType typeA = new DefaultFeatureType(nameOfA, false, null,
-     *         new DefaultAssociationRole(Names.createLocalName("Association to B"), nameOfB),
+     *     FeatureType typeA = new DefaultFeatureType(Map.of(NAME_KEY, nameOfA), false, null,
+     *         new DefaultAssociationRole(Map.of(NAME_KEY, "Association to B"), nameOfB, 1, 1),
      *         // More properties if desired.
      *     );
-     *     FeatureType typeB = new DefaultFeatureType(nameOfB, false, null,
-     *         new DefaultAssociationRole(Names.createLocalName("Association to A"), featureA),
+     *     FeatureType typeB = new DefaultFeatureType(Map.of(NAME_KEY, nameOfB), false, null,
+     *         new DefaultAssociationRole(Map.of(NAME_KEY, "Association to A"), featureA, 1, 1),
      *         // More properties if desired.
      *     );
      *     }
diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperations.java b/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperations.java
index db15ddcb83..7986a36733 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperations.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/FeatureOperations.java
@@ -27,6 +27,7 @@ import org.apache.sis.util.Static;
 import org.apache.sis.util.UnconvertibleObjectException;
 import org.apache.sis.util.collection.WeakHashSet;
 import org.apache.sis.util.resources.Errors;
+import org.apache.sis.setup.GeometryLibrary;
 
 // Branch-dependent imports
 import org.opengis.feature.Feature;
@@ -249,7 +250,7 @@ public final class FeatureOperations extends Static {
      *
      * <h4>Read/write behavior</h4>
      * This operation is read-only. Calls to {@code Attribute.setValue(Envelope)} will result in an
-     * {@link IllegalStateException} to be thrown.
+     * {@link UnsupportedOperationException} to be thrown.
      *
      * @param  identification      the name and other information to be given to the operation.
      * @param  crs                 the Coordinate Reference System in which to express the envelope, or {@code null}.
@@ -265,12 +266,54 @@ public final class FeatureOperations extends Static {
         return POOL.unique(new EnvelopeOperation(identification, crs, geometryAttributes));
     }
 
+    /**
+     * Creates a single geometry from a sequence of points or polylines stored in another property.
+     * When evaluated, this operation reads a feature property containing a sequence of {@code Point}s or {@code Polyline}s.
+     * Those geometries shall be instances of the specified geometry library (e.g. JTS or ESRI).
+     * The merged geometry is usually a {@code Polyline},
+     * unless the sequence of source geometries is empty or contains a single element.
+     * The merged geometry is re-computed every time that the operation is evaluated.
+     *
+     * <h4>Examples</h4>
+     * <p><i>Polylines created from points:</i>
+     * a boat that record it's position every hour.
+     * The input is a list of all positions stored in an attribute with [0 … ∞] multiplicity.
+     * This operation will extract each position and create a line as a new attribute.</p>
+     *
+     * <p><i>Polylines created from other polylines:</i>
+     * a boat that record track every hour.
+     * The input is a list of all tracks stored in an attribute with [0 … ∞] multiplicity.
+     * This operation will extract each track and create a polyline as a new attribute.</p>
+     *
+     * <h4>Read/write behavior</h4>
+     * This operation is read-only. Calls to {@code Attribute.setValue(…)}
+     * will result in an {@link UnsupportedOperationException} to be thrown.
+     *
+     * @param  identification  the name of the operation, together with optional information.
+     * @param  library         the library providing the implementations of geometry objects to read and write.
+     * @param  components      attribute, association or operation providing the geometries to group as a polyline.
+     * @return a feature operation which computes its values by merging points or polylines.
+     *
+     * @since 1.4
+     */
+    public static Operation groupAsPolyline(final Map<String,?> identification, final GeometryLibrary library,
+                                            final PropertyType components)
+    {
+        ArgumentChecks.ensureNonNull("library", library);
+        ArgumentChecks.ensureNonNull("components", components);
+        return POOL.unique(GroupAsPolylineOperation.create(identification, library, components));
+    }
+
     /**
      * Creates an operation which delegates the computation to a given expression.
      * The {@code expression} argument should generally be an instance of
      * {@link org.opengis.filter.Expression},
      * but more generic functions are accepted as well.
      *
+     * <h4>Read/write behavior</h4>
+     * This operation is read-only. Calls to {@code Attribute.setValue(…)}
+     * will result in an {@link UnsupportedOperationException} to be thrown.
+     *
      * @param  <V>             the type of values computed by the expression and assigned to the feature property.
      * @param  identification  the name of the operation, together with optional information.
      * @param  expression      the expression to evaluate on feature instances.
@@ -284,7 +327,7 @@ public final class FeatureOperations extends Static {
                                          final AttributeType<? super V> resultType)
     {
         ArgumentChecks.ensureNonNull("expression", expression);
-        ArgumentChecks.ensureNonNull("result", resultType);
+        ArgumentChecks.ensureNonNull("resultType", resultType);
         return POOL.unique(ExpressionOperation.create(identification, expression, resultType));
     }
 
@@ -295,6 +338,10 @@ public final class FeatureOperations extends Static {
      * This method casts or converts the expression to the expected type by a call to
      * {@link Expression#toValueType(Class)}.
      *
+     * <h4>Read/write behavior</h4>
+     * This operation is read-only. Calls to {@code Attribute.setValue(…)}
+     * will result in an {@link UnsupportedOperationException} to be thrown.
+     *
      * @param  <V>             the type of values computed by the expression and assigned to the feature property.
      * @param  identification  the name of the operation, together with optional information.
      * @param  expression      the expression to evaluate on feature instances.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/Features.java b/core/sis-feature/src/main/java/org/apache/sis/feature/Features.java
index 4d6eca4a0c..cbf2f98185 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/feature/Features.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/Features.java
@@ -49,7 +49,7 @@ import org.opengis.feature.PropertyType;
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Johann Sorel (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.4
  * @since   0.5
  */
 public final class Features extends Static {
@@ -138,13 +138,42 @@ public final class Features extends Static {
      *
      * @since 1.1
      */
+    @SuppressWarnings("unchecked")
     public static Optional<AttributeType<?>> toAttribute(IdentifiedType type) {
-        if (!(type instanceof AttributeType<?>)) {
+        return toIdentifiedType(type, (Class) AttributeType.class);
+    }
+
+    /**
+     * Returns the given type as a {@link FeatureAssociationRole} by casting if possible, or by getting the result type
+     * of an operation. More specifically this method returns the first of the following types which apply:
+     *
+     * <ul>
+     *   <li>If the given type is an instance of {@link FeatureAssociationRole}, then it is returned as-is.</li>
+     *   <li>If the given type is an instance of {@link Operation} and the {@linkplain Operation#getResult()
+     *       result type} is an {@link FeatureAssociationRole}, then that result type is returned.</li>
+     *   <li>If the given type is an instance of {@link Operation} and the {@linkplain Operation#getResult()
+     *       result type} is another operation, then the above check is performed recursively.</li>
+     * </ul>
+     *
+     * @param  type  the data type to express as an attribute type.
+     * @return the association role, or empty if this method cannot find any.
+     *
+     * @since 1.4
+     */
+    public static Optional<FeatureAssociationRole> toAssociation(IdentifiedType type) {
+        return toIdentifiedType(type, FeatureAssociationRole.class);
+    }
+
+    /**
+     * Implementation of {@link #toAttribute(IdentifiedType)} and {@link #toAssociation(IdentifiedType)}.
+     */
+    private static <T> Optional<T> toIdentifiedType(IdentifiedType type, final Class<T> target) {
+        if (!target.isInstance(type)) {
             if (!(type instanceof Operation)) {
                 return Optional.empty();
             }
             type = ((Operation) type).getResult();
-            if (!(type instanceof AttributeType<?>)) {
+            if (!target.isInstance(type)) {
                 if (!(type instanceof Operation)) {
                     return Optional.empty();
                 }
@@ -154,14 +183,14 @@ public final class Features extends Static {
                  * would be thread freeze, we check as a safety.
                  */
                 final Map<IdentifiedType,Boolean> done = new IdentityHashMap<>(4);
-                while (!((type = ((Operation) type).getResult()) instanceof AttributeType<?>)) {
+                while (!target.isInstance(type = ((Operation) type).getResult())) {
                     if (!(type instanceof Operation) || done.put(type, Boolean.TRUE) != null) {
                         return Optional.empty();
                     }
                 }
             }
         }
-        return Optional.of((AttributeType<?>) type);
+        return Optional.of(target.cast(type));
     }
 
     /**
diff --git a/core/sis-feature/src/main/java/org/apache/sis/feature/GroupAsPolylineOperation.java b/core/sis-feature/src/main/java/org/apache/sis/feature/GroupAsPolylineOperation.java
new file mode 100644
index 0000000000..ee206a37d1
--- /dev/null
+++ b/core/sis-feature/src/main/java/org/apache/sis/feature/GroupAsPolylineOperation.java
@@ -0,0 +1,248 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.feature;
+
+import java.util.Map;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.EnumMap;
+import org.opengis.parameter.ParameterDescriptorGroup;
+import org.opengis.parameter.ParameterValueGroup;
+import org.apache.sis.internal.feature.AttributeConvention;
+import org.apache.sis.internal.feature.FeatureUtilities;
+import org.apache.sis.internal.feature.Geometries;
+import org.apache.sis.internal.feature.GeometryWrapper;
+import org.apache.sis.internal.feature.Resources;
+import org.apache.sis.setup.GeometryLibrary;
+
+// Branch-dependent imports
+import org.opengis.feature.Feature;
+import org.opengis.feature.Property;
+import org.opengis.feature.PropertyType;
+import org.opengis.feature.AttributeType;
+import org.opengis.feature.FeatureAssociationRole;
+import org.opengis.feature.Operation;
+
+
+/**
+ * Creates a single (Multi){@code Polyline} instance from a sequence of points or polylines stored in another property.
+ * This is the implementation of {@link FeatureOperations#groupAsPolyline FeatureOperations.groupAsPolyline(…)}.
+ *
+ * @author  Johann Sorel (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
+ * @since   0.8
+ */
+final class GroupAsPolylineOperation extends AbstractOperation {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = -1995248173704801739L;
+
+    /**
+     * The parameter descriptor for the "Group polylines" operation, which does not take any parameter.
+     */
+    private static final ParameterDescriptorGroup EMPTY_PARAMS = FeatureUtilities.parameters("GroupAsPolyline");
+
+    /**
+     * Name of the property to follow in order to get the geometries to add to a polyline.
+     * This property can be an attribute, operation or feature association,
+     * usually with [0 … ∞] multiplicity.
+     */
+    private final String propertyName;
+
+    /**
+     * Whether the property giving components is an association to feature instances.
+     */
+    private final boolean isFeatureAssociation;
+
+    /**
+     * The geometry library.
+     */
+    private final Geometries<?> geometries;
+
+    /**
+     * The {@link #resultType} for each library, created when first needed.
+     * Used for sharing the same instance for all operations using the same library.
+     */
+    private static final EnumMap<GeometryLibrary, DefaultAttributeType<?>> TYPES = new EnumMap<>(GeometryLibrary.class);
+
+    /**
+     * Returns an operation which will group into a single geometry all geometries contained in the specified property.
+     *
+     * @param  identification  the name of the operation, together with optional information.
+     * @param  library         the library providing the implementations of geometry objects to read and write.
+     * @param  components      attribute, association or operation providing the geometries to group as a polyline.
+     */
+    static Operation create(final Map<String,?> identification, final GeometryLibrary library, PropertyType components) {
+        FeatureAssociationRole association = Features.toAssociation(components).orElse(null);
+        if (association != null && association.getMaximumOccurs() == 1) {
+            components = association;
+        } else {
+            association = null;
+            AttributeType<?> attribute = Features.toAttribute(components).orElse(null);
+            if (attribute == null) {
+                throw new IllegalArgumentException(Resources.format(Resources.Keys.IllegalPropertyType_2,
+                                                   components.getName(), components.getClass()));
+            }
+            if (attribute.getMaximumOccurs() <= 1) {
+                return new LinkOperation(identification, components);
+            }
+            components = attribute;
+        }
+        return new GroupAsPolylineOperation(identification, Geometries.implementation(library), components, association != null);
+    }
+
+    /**
+     * Creates an operation which will group into a single polyline all geometries contained in the specified property.
+     * This constructor shall be invoked only after the {@code source} is known to contain collection, i.e. the maximum
+     * number of occurrences of attribute values or feature instances is greater than 1.
+     */
+    private GroupAsPolylineOperation(final Map<String,?> identification, final Geometries<?> geometries,
+                                     final PropertyType components, final boolean isFeatureAssociation)
+    {
+        super(identification);
+        this.geometries = geometries;
+        this.propertyName = components.getName().toString();
+        this.isFeatureAssociation = isFeatureAssociation;
+    }
+
+    /**
+     * Returns an empty parameter descriptor group.
+     */
+    @Override
+    public ParameterDescriptorGroup getParameters() {
+        return EMPTY_PARAMS;
+    }
+
+    /**
+     * Returns the expected result type.
+     */
+    @Override
+    public final AttributeType<?> getResult() {
+        synchronized (TYPES) {
+            return TYPES.computeIfAbsent(geometries.library, (library) -> {
+                var name = Map.of(AbstractIdentifiedType.NAME_KEY, AttributeConvention.ENVELOPE_PROPERTY);
+                return new DefaultAttributeType<>(name, geometries.polylineClass, 1, 1, null);
+            });
+        }
+    }
+
+    /**
+     * Executes the operation on the specified feature.
+     */
+    @Override
+    @SuppressWarnings({"rawtypes", "unchecked"})
+    public final Property apply(Feature feature, ParameterValueGroup parameters) {
+        return new Result<>(getResult(), feature);
+    }
+
+
+    /**
+     * The attribute resulting from execution of the {@link GroupAsPolylineOperation}.
+     * The value is computed when first requested, then cached for this {@code Result} instance only.
+     * Note that the cache is not used when {@link #apply(Feature, ParameterValueGroup)} is invoked,
+     * causing a new value to be computed again. The intent is to behave as if the operation has been
+     * executed at {@code apply(…)} invocation time, even if we deferred the actual execution.
+     *
+     * @param <G> the root geometry class (implementation-dependent).
+     */
+    private final class Result<G> extends OperationResult<G> {
+        /**
+         * For cross-version compatibility.
+         */
+        private static final long serialVersionUID = 5558751012506417903L;
+
+        /**
+         * The result, computed when first needed.
+         */
+        private transient G geometry;
+
+        /**
+         * Creates a new result for an execution on the given feature.
+         * The actual computation is deferred to the first call of {@link #getValue()}.
+         */
+        Result(final AttributeType<G> resultType, final Feature feature) {
+            super(resultType, feature);
+        }
+
+        /**
+         * Computes the geometry from all points or polylines found in the associated feature.
+         *
+         * @throws ClassCastException if a feature, a property value or a geometry is not of the expected class.
+         */
+        @Override
+        public G getValue() {
+            if (geometry == null) {
+                geometry = compute();
+            }
+            return geometry;
+        }
+
+        /**
+         * Computes the geometry when first needed.
+         */
+        private G compute() {
+            /*
+             * Cast to `Collection` should be safe if the constructor
+             * ensured that `Features.getMaximumOccurs(property) > 1`.
+             */
+            Iterator<?> paths = ((Collection<?>) feature.getPropertyValue(propertyName)).iterator();
+            if (isFeatureAssociation) {
+                final Iterator<?> it = paths;
+                paths = new Iterator<Object>() {
+                    @Override public boolean hasNext() {
+                        return it.hasNext();
+                    }
+
+                    @Override public Object next() {
+                        return ((Feature) it.next()).getPropertyValue(AttributeConvention.GEOMETRY);
+                    }
+                };
+            }
+            while (paths.hasNext()) {
+                GeometryWrapper<?> first = geometries.castOrWrap(paths.next());
+                if (first != null) {
+                    final Object geom = first.mergePolylines(paths);
+                    return getType().getValueClass().cast(geom);
+                }
+            }
+            return null;
+        }
+    }
+
+    /**
+     * Computes a hash-code value for this operation.
+     */
+    @Override
+    public int hashCode() {
+        return super.hashCode() + propertyName.hashCode() + geometries.hashCode();
+    }
+
+    /**
+     * Compares this operation with the given object for equality.
+     */
+    @Override
+    public boolean equals(final Object obj) {
+        if (super.equals(obj)) {
+            final GroupAsPolylineOperation that = (GroupAsPolylineOperation) obj;
+            return propertyName.equals(that.propertyName) &&
+                   geometries.equals(that.geometries);
+        }
+        return false;
+    }
+}
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Geometries.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Geometries.java
index 8fe1787c4a..ffd04dfef6 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Geometries.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/Geometries.java
@@ -19,7 +19,6 @@ package org.apache.sis.internal.feature;
 import java.io.Serializable;
 import java.nio.ByteBuffer;
 import java.util.Optional;
-import java.util.Iterator;
 import java.util.logging.Logger;
 import org.opengis.geometry.Envelope;
 import org.opengis.geometry.DirectPosition;
@@ -37,7 +36,6 @@ import org.apache.sis.internal.system.Loggers;
 import org.apache.sis.math.Vector;
 import org.apache.sis.setup.GeometryLibrary;
 import org.apache.sis.util.resources.Errors;
-import org.apache.sis.util.Classes;
 
 
 /**
@@ -566,37 +564,6 @@ public abstract class Geometries<G> implements Serializable {
         return result;
     }
 
-    /**
-     * Merges a sequence of points or polylines into a single polyline instances.
-     * Each previous polyline will be a separated path in the new polyline instances.
-     * The implementation returned by this method is an instance of {@link #rootClass}.
-     *
-     * <p>Contrarily to other methods in this class, this method does <strong>not</strong> unwrap
-     * the geometries contained in {@link GeometryWrapper}. It is caller responsibility to do so
-     * if needed.</p>
-     *
-     * @param  paths  the points or polylines to merge in a single polyline object.
-     * @return the merged polyline, or {@code null} if the given iterator has no element.
-     * @throws ClassCastException if collection elements are not instances of a supported library,
-     *         or not all elements are instances of the same library.
-     */
-    public static Object mergePolylines(final Iterator<?> paths) {
-        while (paths.hasNext()) {
-            final Object first = paths.next();
-            if (first != null) {
-                final Optional<GeometryWrapper<?>> w = wrap(first);
-                if (w.isPresent()) return w.get().mergePolylines(paths);
-                /*
-                 * Use the same exception type than `mergePolylines(…)` implementations.
-                 * Also the same type than exception occurring elsewhere in the code of
-                 * the caller (GroupAsPolylineOperation).
-                 */
-                throw new ClassCastException(Errors.format(Errors.Keys.UnsupportedType_1, Classes.getClass(first)));
-            }
-        }
-        return null;
-    }
-
     /**
      * Creates a wrapper for the given geometry instance.
      * The given object shall be an instance of {@link #rootClass}.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/GeometryWrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/GeometryWrapper.java
index 9644e35484..096e0d1314 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/GeometryWrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/GeometryWrapper.java
@@ -57,7 +57,7 @@ import org.opengis.filter.InvalidFilterValueException;
  * change without warning in future Apache SIS version.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.4
  *
  * @param  <G>  root class of geometry instances of the underlying library (i.e. {@link Geometries#rootClass}).
  *              This is not necessarily the class of the wrapped geometry returned by {@link #implementation()}.
@@ -185,10 +185,10 @@ public abstract class GeometryWrapper<G> extends AbstractGeometry implements Geo
      * (it is caller responsibility to unwrap if needed).</p>
      *
      * @param  paths  the points or polylines to merge in a single polyline instance.
-     * @return the merged polyline (may be the wrapper geometry but never {@code null}).
+     * @return the merged polyline (may be the underlying geometry of {@code this} but never {@code null}).
      * @throws ClassCastException if collection elements are not instances of the point or geometry class.
      */
-    protected abstract G mergePolylines(final Iterator<?> paths);
+    public abstract G mergePolylines(final Iterator<?> paths);
 
     /**
      * Applies a filter predicate between this geometry and another geometry.
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/esri/Wrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/esri/Wrapper.java
index c4c5d63885..68799cd376 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/esri/Wrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/esri/Wrapper.java
@@ -158,7 +158,7 @@ final class Wrapper extends GeometryWithCRS<Geometry> {
      * @throws ClassCastException if an element in the iterator is not an ESRI geometry.
      */
     @Override
-    protected Geometry mergePolylines(final Iterator<?> polylines) {
+    public Geometry mergePolylines(final Iterator<?> polylines) {
         final Polyline path = new Polyline();
         boolean lineTo = false;
 add:    for (Geometry next = geometry;;) {
@@ -181,7 +181,7 @@ add:    for (Geometry next = geometry;;) {
                 lineTo = false;
             }
             /*
-             * 'polylines.hasNext()' check is conceptually part of 'for' instruction,
+             * `polylines.hasNext()` check is conceptually part of `for` instruction,
              * except that we need to skip this condition during the first iteration.
              */
             do if (!polylines.hasNext()) break add;
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/PointWrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/PointWrapper.java
index 7a8369092d..10db10fd4b 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/PointWrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/PointWrapper.java
@@ -119,7 +119,7 @@ final class PointWrapper extends GeometryWithCRS<Shape> {
      * @throws ClassCastException if an element in the iterator is not a {@link Shape} or a {@link Point2D}.
      */
     @Override
-    protected Shape mergePolylines(final Iterator<?> polylines) {
+    public Shape mergePolylines(final Iterator<?> polylines) {
         return Wrapper.mergePolylines(point, polylines);
     }
 
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/Wrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/Wrapper.java
index 8b545d752e..8326eef294 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/Wrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/j2d/Wrapper.java
@@ -155,7 +155,7 @@ final class Wrapper extends GeometryWithCRS<Shape> {
      * @throws ClassCastException if an element in the iterator is not a {@link Shape} or a {@link Point2D}.
      */
     @Override
-    protected Shape mergePolylines(final Iterator<?> polylines) {
+    public Shape mergePolylines(final Iterator<?> polylines) {
         return mergePolylines(geometry, polylines);
     }
 
diff --git a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/jts/Wrapper.java b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/jts/Wrapper.java
index 547543d23e..b4c76fea54 100644
--- a/core/sis-feature/src/main/java/org/apache/sis/internal/feature/jts/Wrapper.java
+++ b/core/sis-feature/src/main/java/org/apache/sis/internal/feature/jts/Wrapper.java
@@ -281,7 +281,7 @@ final class Wrapper extends GeometryWrapper<Geometry> {
      * @throws ClassCastException if an element in the iterator is not a JTS geometry.
      */
     @Override
-    protected Geometry mergePolylines(final Iterator<?> polylines) {
+    public Geometry mergePolylines(final Iterator<?> polylines) {
         final List<Coordinate> coordinates = new ArrayList<>();
         final List<Geometry> lines = new ArrayList<>();
         boolean isFloat = true;
diff --git a/core/sis-feature/src/test/java/org/apache/sis/internal/feature/GeometriesTestCase.java b/core/sis-feature/src/test/java/org/apache/sis/internal/feature/GeometriesTestCase.java
index a73c1f30e3..43503a539f 100644
--- a/core/sis-feature/src/test/java/org/apache/sis/internal/feature/GeometriesTestCase.java
+++ b/core/sis-feature/src/test/java/org/apache/sis/internal/feature/GeometriesTestCase.java
@@ -120,7 +120,7 @@ public abstract class GeometriesTestCase extends TestCase {
     }
 
     /**
-     * Tests {@link Geometries#mergePolylines(Iterator)} (or actually tests its strategy).
+     * Tests {@link GeometryWrapper#mergePolylines(Iterator)} (or actually tests its strategy).
      * This method verifies the polylines by a call to {@link GeometryWrapper#getEnvelope()}.
      * Subclasses should perform more extensive tests by verifying the {@link #geometry} field.
      */
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/GroupAsPolylineOperation.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/GroupAsPolylineOperation.java
deleted file mode 100644
index 6303da7ccc..0000000000
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/GroupAsPolylineOperation.java
+++ /dev/null
@@ -1,211 +0,0 @@
-/*
- * Licensed to the Apache Software Foundation (ASF) under one or more
- * contributor license agreements.  See the NOTICE file distributed with
- * this work for additional information regarding copyright ownership.
- * The ASF licenses this file to You under the Apache License, Version 2.0
- * (the "License"); you may not use this file except in compliance with
- * the License.  You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package org.apache.sis.internal.storage.gpx;
-
-import java.util.Map;
-import java.util.Iterator;
-import java.util.Collection;
-import org.opengis.parameter.ParameterDescriptorGroup;
-import org.opengis.parameter.ParameterValueGroup;
-import org.apache.sis.feature.AbstractAttribute;
-import org.apache.sis.feature.AbstractOperation;
-import org.apache.sis.feature.DefaultAttributeType;
-import org.apache.sis.internal.feature.AttributeConvention;
-import org.apache.sis.internal.feature.FeatureUtilities;
-import org.apache.sis.internal.feature.Geometries;
-import org.apache.sis.util.resources.Errors;
-
-// Branch-dependent imports
-import org.opengis.feature.Feature;
-import org.opengis.feature.Property;
-import org.opengis.feature.Attribute;
-import org.opengis.feature.AttributeType;
-
-
-/**
- * Creates a single (Multi){@code Polyline} instance from a sequence of points or polylines stored in another property.
- * This base class expects a sequence of {@code Point} or {@code Polyline} instances as input.
- * The single (Multi){@code Polyline} instance is re-computed every time this property is requested.
- *
- * <h2>Examples</h2>
- * <p><i>Polylines created from points:</i>
- * a boat that record it's position every hour.
- * The list of all positions is stored in an attribute with [0 … ∞] multiplicity.
- * This class will extract each position and create a line as a new attribute.
- * Any change applied to the positions will be visible on the line.</p>
- *
- * <p><i>Polylines created from other polylines:</i>
- * a boat that record track every hour.
- * The list of all tracks is stored in an attribute with [0 … ∞] multiplicity.
- * This class will extract each track and create a polyline as a new attribute.
- * Any change applied to the tracks will be visible on the polyline.</p>
- *
- * @author  Johann Sorel (Geomatys)
- * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
- * @since   0.8
- */
-final class GroupAsPolylineOperation extends AbstractOperation {
-    /**
-     * For cross-version compatibility.
-     */
-    private static final long serialVersionUID = 7898989085371304159L;
-
-    /**
-     * The parameter descriptor for the "Group polylines" operation, which does not take any parameter.
-     */
-    private static final ParameterDescriptorGroup EMPTY_PARAMS = FeatureUtilities.parameters("GroupPolylines");
-
-    /**
-     * Name of the property to follow in order to get the geometries to add to a polyline.
-     * This property shall be a feature association, usually with [0 … ∞] multiplicity.
-     */
-    private final String association;
-
-    /**
-     * The expected result type to be returned by {@link #getResult()}.
-     */
-    @SuppressWarnings("serial")
-    private final AttributeType<?> result;
-
-    /**
-     * Creates a new operation which will look for geometries in the given feature association.
-     *
-     * @param  identification  name and other information to be given to this operation.
-     * @param  association     name of the property to follow in order to get the geometries to add to a polyline.
-     * @param  result          the expected result type to be returned by {@link #getResult()}.
-     */
-    GroupAsPolylineOperation(final Map<String,?> identification, final String association, final AttributeType<?> result) {
-        super(identification);
-        this.association = association;
-        this.result = result;
-    }
-
-    /**
-     * Creates the {@code result} argument for the constructor. This creation is provided in a separated method
-     * because the same instance will be shared by many {@code GroupAsPolylineOperation} instances.
-     *
-     * @param  geometries  accessor to the geometry implementation in use (Java2D, ESRI or JTS).
-     */
-    static <G> AttributeType<? extends G> getResult(final Geometries<G> geometries) {
-        return new DefaultAttributeType<>(Map.of(NAME_KEY, AttributeConvention.ENVELOPE_PROPERTY),
-                                          geometries.polylineClass, 1, 1, null);
-    }
-
-    /**
-     * Returns an empty parameter descriptor group.
-     */
-    @Override
-    public ParameterDescriptorGroup getParameters() {
-        return EMPTY_PARAMS;
-    }
-
-    /**
-     * Returns the expected result type.
-     */
-    @Override
-    public final AttributeType<?> getResult() {
-        return result;
-    }
-
-    /**
-     * Executes the operation on the specified feature with the specified parameters.
-     * If the geometries have changed since last time this method has been invoked,
-     * the result will be recomputed.
-     */
-    @Override
-    @SuppressWarnings({"rawtypes", "unchecked"})
-    public final Property apply(Feature feature, ParameterValueGroup parameters) {
-        return new Result(feature, association, result);
-    }
-
-
-    /**
-     * The attribute resulting from execution if the {@link GroupAsPolylineOperation}.
-     * The value is computed when first requested, then cached for this {@code Result} instance only.
-     * Note that the cache is not used when {@link #apply(Feature, ParameterValueGroup)} is invoked,
-     * causing a new value to be computed again. The intent is to behave as if the operation has been
-     * executed at {@code apply(…)} invocation time, even if we deferred the actual execution.
-     *
-     * @param  <G>  the root geometry class (implementation-dependent).
-     */
-    private static final class Result<G> extends AbstractAttribute<G> {
-        /**
-         * For cross-version compatibility.
-         */
-        private static final long serialVersionUID = -8872834506769732436L;
-
-        /**
-         * The feature on which to execute the operation.
-         */
-        @SuppressWarnings("serial")     // Most SIS implementations are serializable.
-        private final Feature feature;
-
-        /**
-         * Name of the property to follow in order to get the geometries to add to a polyline.
-         * This property shall be a feature association, usually with [0 … ∞] multiplicity.
-         */
-        private final String association;
-
-        /**
-         * The result, computed when first needed.
-         */
-        private transient G geometry;
-
-        /**
-         * Creates a new result for an execution on the given feature.
-         * The actual computation is deferred to the first call of {@link #getValue()}.
-         */
-        Result(final Feature feature, final String association, final AttributeType<G> result) {
-            super(result);
-            this.feature = feature;
-            this.association = association;
-        }
-
-        /**
-         * Computes the geometry from all points or polylines found in the associated feature.
-         *
-         * @throws ClassCastException if a feature, a property value or a geometry is not of the expected class.
-         *         This exception should not happen since we use {@link #feature} in contexts where types are known.
-         */
-        @Override
-        public G getValue() {
-            if (geometry == null) {
-                final Iterator<?> it = ((Collection<?>) feature.getPropertyValue(association)).iterator();
-                final Object geom = Geometries.mergePolylines(new Iterator<Object>() {
-                    @Override public boolean hasNext() {
-                        return it.hasNext();
-                    }
-
-                    @Override public Object next() {
-                        return ((Feature) it.next()).getPropertyValue(AttributeConvention.GEOMETRY);
-                    }
-                });
-                geometry = getType().getValueClass().cast(geom);
-            }
-            return geometry;
-        }
-
-        /**
-         * Does not allow modification of this attribute.
-         */
-        @Override
-        public void setValue(G value) {
-            throw new UnsupportedOperationException(Errors.format(Errors.Keys.UnmodifiableObject_1, Attribute.class));
-        }
-    }
-}
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Types.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Types.java
index bdefc58aad..c552af357d 100644
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Types.java
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Types.java
@@ -21,7 +21,6 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.HashMap;
 import java.time.temporal.Temporal;
-import org.opengis.util.ScopedName;
 import org.opengis.util.GenericName;
 import org.opengis.util.NameFactory;
 import org.opengis.util.FactoryException;
@@ -35,6 +34,7 @@ import org.apache.sis.storage.FeatureNaming;
 import org.apache.sis.storage.IllegalNameException;
 import org.apache.sis.referencing.CommonCRS;
 import org.apache.sis.feature.AbstractIdentifiedType;
+import org.apache.sis.feature.DefaultAssociationRole;
 import org.apache.sis.feature.FeatureOperations;
 import org.apache.sis.feature.builder.FeatureTypeBuilder;
 import org.apache.sis.feature.builder.PropertyTypeBuilder;
@@ -47,8 +47,8 @@ import org.apache.sis.util.ResourceInternationalString;
 import org.apache.sis.util.iso.DefaultNameFactory;
 
 // Branch-dependent imports
-import org.opengis.feature.AttributeType;
 import org.opengis.feature.FeatureType;
+import org.opengis.feature.Operation;
 
 
 /**
@@ -57,7 +57,8 @@ import org.opengis.feature.FeatureType;
  * nevertheless allows definition of alternative {@code Types} with names created by different factories.
  *
  * @author  Johann Sorel (Geomatys)
- * @version 0.8
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.4
  * @since   0.8
  */
 final class Types {
@@ -132,8 +133,7 @@ final class Types {
     {
         geometries = Geometries.implementation(library);
         final Map<String,InternationalString[]> resources = new HashMap<>();
-        final ScopedName    geomName = AttributeConvention.GEOMETRY_PROPERTY;
-        final Map<String,?> geomInfo = Map.of(AbstractIdentifiedType.NAME_KEY, geomName);
+        final Map<String,?> geomInfo = Map.of(AbstractIdentifiedType.NAME_KEY, AttributeConvention.GEOMETRY_PROPERTY);
         final Map<String,?> envpInfo = Map.of(AbstractIdentifiedType.NAME_KEY, AttributeConvention.ENVELOPE_PROPERTY);
         /*
          * The parent of all FeatureTypes to be created in this constructor.
@@ -180,7 +180,7 @@ final class Types {
          * └──────────────────┴────────────────┴───────────────────────┴──────────────┘
          */
         builder.clear().setSuperTypes(parent).setNameSpace(Tags.PREFIX).setName("WayPoint");
-        builder.addAttribute(GeometryType.POINT).setName(geomName)
+        builder.addAttribute(GeometryType.POINT).setName(AttributeConvention.GEOMETRY_PROPERTY)
                 .setCRS(CommonCRS.WGS84.normalizedGeographic())
                 .addRole(AttributeRole.DEFAULT_GEOMETRY);
         builder.setDefaultMultiplicity(0, 1);
@@ -221,8 +221,7 @@ final class Types {
          * │ rtept          │ WayPoint       │ gpx:wptType           │   [0 … ∞]    │
          * └────────────────┴────────────────┴───────────────────────┴──────────────┘
          */
-        final AttributeType<?> groupResult = GroupAsPolylineOperation.getResult(geometries);
-        GroupAsPolylineOperation groupOp = new GroupAsPolylineOperation(geomInfo, Tags.ROUTE_POINTS, groupResult);
+        Operation groupOp = groupAsPolyline(geomInfo, Tags.ROUTE_POINTS, wayPoint);
         builder.clear().setSuperTypes(parent).setNameSpace(Tags.PREFIX).setName("Route");
         builder.addProperty(groupOp);
         builder.addProperty(FeatureOperations.envelope(envpInfo, null, groupOp));
@@ -247,7 +246,7 @@ final class Types {
          * │ trkpt          │ WayPoint │ gpx:wptType │   [0 … ∞]    │
          * └────────────────┴──────────┴─────────────┴──────────────┘
          */
-        groupOp = new GroupAsPolylineOperation(geomInfo, Tags.TRACK_POINTS, groupResult);
+        groupOp = groupAsPolyline(geomInfo, Tags.TRACK_POINTS, wayPoint);
         builder.clear().setSuperTypes(parent).setNameSpace(Tags.PREFIX).setName("TrackSegment");
         builder.addProperty(groupOp);
         builder.addProperty(FeatureOperations.envelope(envpInfo, null, groupOp));
@@ -272,7 +271,7 @@ final class Types {
          * │ trkseg         │ TrackSegment   │ gpx:trksegType        │   [0 … ∞]    │
          * └────────────────┴────────────────┴───────────────────────┴──────────────┘
          */
-        groupOp = new GroupAsPolylineOperation(geomInfo, Tags.TRACK_SEGMENTS, groupResult);
+        groupOp = groupAsPolyline(geomInfo, Tags.TRACK_SEGMENTS, trackSegment);
         builder.clear().setSuperTypes(parent).setNameSpace(Tags.PREFIX).setName("Track");
         builder.addProperty(groupOp);
         builder.addProperty(FeatureOperations.envelope(envpInfo, null, groupOp));
@@ -317,4 +316,16 @@ final class Types {
         }
         return builder.build();
     }
+
+    /**
+     * Creates a new operation which will group the geometries in the given property into a single polyline.
+     *
+     * @param geomInfo    the name of the operation, together with optional information.
+     * @param components  name of the property providing the geometries to group as a polyline.
+     * @param type        type of the property identified by {@code components}.
+     */
+    private Operation groupAsPolyline(final Map<String,?> geomInfo, final String components, final FeatureType type) {
+        var c = new DefaultAssociationRole(Map.of(DefaultAssociationRole.NAME_KEY, components), type, 1, 1);
+        return FeatureOperations.groupAsPolyline(geomInfo, geometries.library, c);
+    }
 }