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 2022/12/03 22:44:09 UTC

[sis] 02/02: GPX data store should implement `WritableFeatureSet`.

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 138a82a9e84a52c093d5b0314773427f68edea7c
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Sat Dec 3 23:43:15 2022 +0100

    GPX data store should implement `WritableFeatureSet`.
    
    https://issues.apache.org/jira/browse/SIS-411
---
 .../java/org/apache/sis/test/MetadataAssert.java   |   4 +-
 .../apache/sis/test/xml/DocumentComparator.java    |   7 +-
 .../java/org/apache/sis/test/xml/package-info.java |   2 +-
 .../java/org/apache/sis/internal/jdk9/JDK9.java    |  13 +
 .../sis/storage/IllegalFeatureTypeException.java   |   2 +-
 .../apache/sis/internal/storage/gpx/Reader.java    |   2 +-
 .../org/apache/sis/internal/storage/gpx/Store.java | 103 +++++++-
 .../apache/sis/internal/storage/gpx/Updater.java   |  89 +++++++
 .../apache/sis/internal/storage/gpx/Writer.java    |  12 +-
 .../storage/xml/stream/RewriteOnUpdate.java        | 283 +++++++++++++++++++++
 .../internal/storage/xml/stream/StaxDataStore.java |  83 +++---
 .../storage/xml/stream/StaxStreamWriter.java       |  12 +-
 .../internal/storage/xml/stream/package-info.java  |   2 +-
 .../sis/internal/storage/gpx/UpdaterTest.java      | 179 +++++++++++++
 .../org/apache/sis/test/suite/GPXTestSuite.java    |   3 +-
 15 files changed, 737 insertions(+), 59 deletions(-)

diff --git a/core/sis-metadata/src/test/java/org/apache/sis/test/MetadataAssert.java b/core/sis-metadata/src/test/java/org/apache/sis/test/MetadataAssert.java
index 703fb6f0e8..0081be869e 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/test/MetadataAssert.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/test/MetadataAssert.java
@@ -134,8 +134,8 @@ public strictfp class MetadataAssert extends Assert {
      *
      * <ul>
      *   <li>{@link org.w3c.dom.Node}: used directly without further processing.</li>
-     *   <li>{@link java.io.File}, {@link java.net.URL} or {@link java.net.URI}: the
-     *       stream is opened and parsed as a XML document.</li>
+     *   <li>{@link java.nio.file.Path}, {@link java.io.File}, {@link java.net.URL} or {@link java.net.URI}:
+     *       the stream is opened and parsed as a XML document.</li>
      *   <li>{@link String}: The string content is parsed directly as a XML document.</li>
      * </ul>
      *
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/test/xml/DocumentComparator.java b/core/sis-metadata/src/test/java/org/apache/sis/test/xml/DocumentComparator.java
index 908b307cc8..d8388b9870 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/test/xml/DocumentComparator.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/test/xml/DocumentComparator.java
@@ -22,6 +22,8 @@ import java.util.List;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.ArrayList;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.net.URI;
 import java.net.URL;
 import java.io.File;
@@ -71,7 +73,7 @@ import static org.apache.sis.util.CharSequences.trimWhitespaces;
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Guilhem Legal (Geomatys)
- * @version 1.0
+ * @version 1.3
  *
  * @see TestCase
  * @see org.apache.sis.test.MetadataAssert#assertXmlEquals(Object, Object, String[])
@@ -190,7 +192,7 @@ public strictfp class DocumentComparator {
      *
      * <ul>
      *   <li>{@link Node}; used directly without further processing.</li>
-     *   <li>{@link File}, {@link URL} or {@link URI}: the stream is opened and parsed as a XML document.</li>
+     *   <li>{@link Path}, {@link File}, {@link URL} or {@link URI}: the stream is opened and parsed as a XML document.</li>
      *   <li>{@link String}: The string content is parsed directly as a XML document.</li>
      * </ul>
      *
@@ -248,6 +250,7 @@ public strictfp class DocumentComparator {
         if (input instanceof File)        return new FileInputStream((File) input);
         if (input instanceof URI)         return ((URI) input).toURL().openStream();
         if (input instanceof URL)         return ((URL) input).openStream();
+        if (input instanceof Path)        return Files.newInputStream((Path) input);
         if (input instanceof String)      return new ByteArrayInputStream(input.toString().getBytes("UTF-8"));
         throw new IOException("Cannot handle input type: " + (input != null ? input.getClass() : input));
     }
diff --git a/core/sis-metadata/src/test/java/org/apache/sis/test/xml/package-info.java b/core/sis-metadata/src/test/java/org/apache/sis/test/xml/package-info.java
index da318ef7df..e4405984f3 100644
--- a/core/sis-metadata/src/test/java/org/apache/sis/test/xml/package-info.java
+++ b/core/sis-metadata/src/test/java/org/apache/sis/test/xml/package-info.java
@@ -25,7 +25,7 @@
  * in any future version without notice.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.3
  * @since   1.0
  * @module
  */
diff --git a/core/sis-utility/src/main/java/org/apache/sis/internal/jdk9/JDK9.java b/core/sis-utility/src/main/java/org/apache/sis/internal/jdk9/JDK9.java
index ec1de1d236..047b6f326b 100644
--- a/core/sis-utility/src/main/java/org/apache/sis/internal/jdk9/JDK9.java
+++ b/core/sis-utility/src/main/java/org/apache/sis/internal/jdk9/JDK9.java
@@ -16,6 +16,7 @@
  */
 package org.apache.sis.internal.jdk9;
 
+import java.io.IOException;
 import java.nio.Buffer;
 import java.nio.ByteBuffer;
 import java.nio.DoubleBuffer;
@@ -23,6 +24,8 @@ import java.nio.FloatBuffer;
 import java.nio.IntBuffer;
 import java.nio.LongBuffer;
 import java.nio.ShortBuffer;
+import java.nio.file.Files;
+import java.nio.file.Path;
 import java.util.Arrays;
 import java.util.Set;
 import java.util.List;
@@ -32,6 +35,7 @@ import java.util.HashMap;
 import java.util.LinkedHashSet;
 import java.util.Map;
 import java.util.Optional;
+import java.util.StringJoiner;
 import java.util.function.Consumer;
 import java.util.stream.Stream;
 import org.apache.sis.internal.util.CollectionsExt;
@@ -350,4 +354,13 @@ public final class JDK9 {
     public static <T> List<T> toList(final Stream<T> s) {
         return (List<T>) UnmodifiableArrayList.wrap(s.toArray());
     }
+
+    /**
+     * Placeholder for {@link Files#readString(Path)}.
+     */
+    public static String readString(final Path path) throws IOException {
+        final StringJoiner j = new StringJoiner("\n");
+        Files.readAllLines(path).forEach(j::add);
+        return j.toString();
+    }
 }
diff --git a/storage/sis-storage/src/main/java/org/apache/sis/storage/IllegalFeatureTypeException.java b/storage/sis-storage/src/main/java/org/apache/sis/storage/IllegalFeatureTypeException.java
index 809f6b2e79..4e9352ca54 100644
--- a/storage/sis-storage/src/main/java/org/apache/sis/storage/IllegalFeatureTypeException.java
+++ b/storage/sis-storage/src/main/java/org/apache/sis/storage/IllegalFeatureTypeException.java
@@ -69,7 +69,7 @@ public class IllegalFeatureTypeException extends DataStoreException {
     /**
      * Creates an exception with a default message in the given locale.
      *
-     * @param locale     the message locale.
+     * @param locale     the locale for the message, or {@code null} for the default locale.
      * @param format     short name of the format that do not accept the given feature type.
      * @param dataType   name of the feature type that cannot be accepted by the data store.
      */
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Reader.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Reader.java
index ebf94fb328..09f7f574db 100644
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Reader.java
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Reader.java
@@ -100,7 +100,7 @@ final class Reader extends StaxStreamReader {
      * @throws IOException if an error occurred while preparing the input stream.
      * @throws Exception if another kind of error occurred while closing a previous stream.
      */
-    public Reader(final Store owner) throws Exception {
+    Reader(final Store owner) throws Exception {
         super(owner);
     }
 
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Store.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Store.java
index 2e353f687e..5d14b374ef 100644
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Store.java
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Store.java
@@ -17,8 +17,12 @@
 package org.apache.sis.internal.storage.gpx;
 
 import java.util.Optional;
+import java.util.Iterator;
+import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
+import java.io.IOException;
 import java.io.UncheckedIOException;
 import java.net.URISyntaxException;
 import org.opengis.util.NameFactory;
@@ -26,12 +30,13 @@ import org.opengis.util.FactoryException;
 import org.opengis.geometry.Envelope;
 import org.opengis.metadata.Metadata;
 import org.opengis.metadata.distribution.Format;
-import org.apache.sis.storage.FeatureSet;
+import org.apache.sis.storage.WritableFeatureSet;
 import org.apache.sis.storage.StorageConnector;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.ConcurrentReadException;
 import org.apache.sis.storage.IllegalNameException;
+import org.apache.sis.storage.IllegalFeatureTypeException;
 import org.apache.sis.internal.system.DefaultFactories;
 import org.apache.sis.internal.storage.StoreUtilities;
 import org.apache.sis.internal.storage.xml.stream.StaxDataStore;
@@ -52,6 +57,8 @@ import org.opengis.feature.FeatureType;
 
 /**
  * A data store backed by GPX files.
+ * This store does not cache the feature instances.
+ * Any new {@linkplain #features(boolean) request for features} will re-read from the file.
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
@@ -59,7 +66,7 @@ import org.opengis.feature.FeatureType;
  * @since   0.8
  * @module
  */
-public final class Store extends StaxDataStore implements FeatureSet {
+public final class Store extends StaxDataStore implements WritableFeatureSet {
     /**
      * Version of the GPX file, or {@code null} if unknown.
      */
@@ -73,6 +80,7 @@ public final class Store extends StaxDataStore implements FeatureSet {
     /**
      * If a reader has been created for parsing the {@linkplain #metadata} and has not yet been used
      * for iterating over the features, that reader. Otherwise {@code null}.
+     * Used for continuing XML parsing after metadata header instead of closing and reopening the file.
      */
     private Reader reader;
 
@@ -208,8 +216,26 @@ public final class Store extends StaxDataStore implements FeatureSet {
         return types.names.get(this, name);
     }
 
+    /**
+     * Verifies the type of feature instances in this feature set.
+     * This method does nothing if the specified type is equal to {@link #getType()},
+     * or throws {@link IllegalFeatureTypeException} otherwise.
+     *
+     * @param  newType  new feature type definition (not {@code null}).
+     * @throws DataStoreException if the given type is not compatible with the types supported by the store.
+     */
+    @Override
+    public void updateType(final FeatureType newType) throws DataStoreException {
+        if (!newType.equals(getType())) {
+            throw new IllegalFeatureTypeException(getLocale(), StoreProvider.NAME, newType.getName());
+        }
+    }
+
     /**
      * Returns the stream of features.
+     * This store does not cache the features. Any new iteration over features will re-read from the file.
+     * The XML file is kept open until the feature stream is closed;
+     * callers should not modify the file while an iteration is in progress.
      *
      * @param  parallel  ignored in current implementation.
      * @return a stream over all features in the XML file.
@@ -233,6 +259,68 @@ public final class Store extends StaxDataStore implements FeatureSet {
         return features.onClose(r);
     }
 
+    /**
+     * Appends new feature instances in this {@code FeatureSet}.
+     * Any feature already present in this {@link FeatureSet} will remain unmodified.
+     *
+     * @param  features  feature instances to append in this {@code FeatureSet}.
+     * @throws DataStoreException if the feature stream cannot be obtained or updated.
+     */
+    @Override
+    public synchronized void add(final Iterator<? extends Feature> features) throws DataStoreException {
+        try (Updater updater = updater()) {
+            updater.add(features);
+            updater.flush();
+        }
+    }
+
+    /**
+     * Removes all feature instances from this {@code FeatureSet} which matches the given predicate.
+     *
+     * @param  filter  a predicate which returns {@code true} for feature instances to be removed.
+     * @return {@code true} if any elements were removed.
+     * @throws DataStoreException if the feature stream cannot be obtained or updated.
+     */
+    @Override
+    public synchronized boolean removeIf(final Predicate<? super Feature> filter) throws DataStoreException {
+        try (Updater updater = updater()) {
+            return updater.removeIf(filter);
+        }
+    }
+
+    /**
+     * Updates all feature instances from this {@code FeatureSet} which match the given predicate.
+     * If the given operator returns {@code null}, then the filtered feature is removed.
+     *
+     * @param  filter       a predicate which returns {@code true} for feature instances to be updated.
+     * @param  replacement  operation called for each matching {@link Feature} instance. May return {@code null}.
+     * @throws DataStoreException if the feature stream cannot be obtained or updated.
+     */
+    @Override
+    public synchronized void replaceIf(final Predicate<? super Feature> filter, final UnaryOperator<Feature> replacement)
+            throws DataStoreException
+    {
+        try (Updater updater = updater()) {
+            updater.replaceIf(filter, replacement);
+            updater.flush();
+        }
+    }
+
+    /**
+     * Returns the helper object to use for updating the GPX file.
+     *
+     * @todo In current version, we flush the updater after each write operation.
+     *       In a future version, we should keep it in a private field and flush
+     *       only after some delay, on close, or before a read operation.
+     */
+    private Updater updater() throws DataStoreException {
+        try {
+            return new Updater(this, getSpecifiedPath());
+        } catch (IOException e) {
+            throw new DataStoreException(e);
+        }
+    }
+
     /**
      * Replaces the content of this GPX file by the given metadata and features.
      *
@@ -240,13 +328,18 @@ public final class Store extends StaxDataStore implements FeatureSet {
      * @param  features  the features to write, or {@code null} if none.
      * @throws ConcurrentReadException if the {@code features} stream was provided by this data store.
      * @throws DataStoreException if an error occurred while writing the data.
+     *
+     * @deprecated To be replaced by {@link #add(Iterator)}, after we resolved how to specify metadata.
+     *
+     * @see <a href="https://issues.apache.org/jira/browse/SIS-411">SIS-411</a>
      */
+    @Deprecated
     public synchronized void write(final Metadata metadata, final Stream<? extends Feature> features) throws DataStoreException {
         try {
             /*
              * If we created a reader for reading metadata, we need to close that reader now otherwise the call
-             * to 'new Writer(…)' will fail.  Note that if that reader was in use by someone else, the 'reader'
-             * field would be null and the 'new Writer(…)' call should detect that a reader is in use somewhere.
+             * to `new Writer(…)` will fail.  Note that if that reader was in use by someone else, the `reader`
+             * field would be null and the `new Writer(…)` call should detect that a reader is in use somewhere.
              */
             final Reader r = reader;
             if (r != null) {
@@ -256,7 +349,7 @@ public final class Store extends StaxDataStore implements FeatureSet {
             /*
              * Get the writer if no read or other write operation is in progress, then write the data.
              */
-            try (Writer writer = new Writer(this, org.apache.sis.internal.storage.gpx.Metadata.castOrCopy(metadata, locale))) {
+            try (Writer writer = new Writer(this, org.apache.sis.internal.storage.gpx.Metadata.castOrCopy(metadata, locale), null)) {
                 writer.writeStartDocument();
                 if (features != null) {
                     features.forEachOrdered(writer);
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Updater.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Updater.java
new file mode 100644
index 0000000000..8a739175ff
--- /dev/null
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Updater.java
@@ -0,0 +1,89 @@
+/*
+ * 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.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.stream.Stream;
+import org.apache.sis.internal.storage.xml.stream.RewriteOnUpdate;
+import org.apache.sis.internal.storage.xml.stream.StaxStreamWriter;
+import org.apache.sis.storage.DataStoreException;
+import org.opengis.feature.Feature;
+
+
+/**
+ * Updates the content of a GPX file by rewriting it.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.3
+ * @since   1.3
+ * @module
+ */
+final class Updater extends RewriteOnUpdate {
+    /**
+     * The metadata to write.
+     */
+    private Metadata metadata;
+
+    /**
+     * Creates an updater for the given source of features.
+     *
+     * @param  source    the set of features to update.
+     * @param  location  the main file, or {@code null} if unknown.
+     * @throws IOException if an error occurred while determining whether the file is empty.
+     */
+    Updater(final Store source, final Path location) throws IOException {
+        super(source, location);
+    }
+
+    /**
+     * Returns the stream of features to copy.
+     *
+     * @return all features contained in the dataset.
+     * @throws DataStoreException if an error occurred while fetching the features.
+     */
+    @Override
+    protected Stream<? extends Feature> features() throws DataStoreException {
+        metadata = Metadata.castOrCopy(source.getMetadata(), getLocale());
+        return super.features();
+    }
+
+    /**
+     * Creates an initially empty temporary file.
+     *
+     * @return the temporary file.
+     * @throws IOException if an error occurred while creating the temporary file.
+     */
+    @Override
+    protected Path createTemporaryFile() throws IOException {
+        return Files.createTempFile(StoreProvider.NAME, ".xml");
+    }
+
+    /**
+     * Creates a new GPX writer for an output in the specified file.
+     *
+     * @param  temporary  the temporary stream where to write, or {@code null} for writing directly in the store file.
+     * @return the writer where to copy updated features.
+     * @throws Exception if an error occurred while creating the writer.
+     */
+    @Override
+    protected StaxStreamWriter createWriter(OutputStream temporary) throws Exception {
+        return new Writer((Store) source, metadata, temporary);
+    }
+}
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Writer.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Writer.java
index 840873ab8b..97d33b705a 100644
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Writer.java
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/gpx/Writer.java
@@ -17,6 +17,7 @@
 package org.apache.sis.internal.storage.gpx;
 
 import java.io.IOException;
+import java.io.OutputStream;
 import java.util.Collection;
 import javax.xml.stream.XMLStreamException;
 import javax.xml.bind.JAXBException;
@@ -39,7 +40,7 @@ import org.opengis.feature.FeatureType;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.3
  * @since   0.8
  * @module
  */
@@ -57,16 +58,17 @@ final class Writer extends StaxStreamWriter {
     /**
      * Creates a new GPX writer for the given data store.
      *
-     * @param  owner     the data store for which this writer is created.
-     * @param  metadata  the metadata to write, or {@code null} if none.
+     * @param  owner      the data store for which this writer is created.
+     * @param  metadata   the metadata to write, or {@code null} if none.
+     * @param  temporary  the temporary stream where to write, or {@code null} for the main storage.
      * @throws DataStoreException if the output type is not recognized or the data store is closed.
      * @throws XMLStreamException if an error occurred while opening the XML file.
      * @throws IOException if an error occurred while preparing the output stream.
      */
-    public Writer(final Store owner, final Metadata metadata)
+    Writer(final Store owner, final Metadata metadata, final OutputStream temporary)
             throws DataStoreException, XMLStreamException, IOException
     {
-        super(owner);
+        super(owner, temporary);
         this.metadata = metadata;
         final Version ver = owner.version;
         if (ver != null && ver.compareTo(StoreProvider.V1_0, 2) <= 0) {
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/RewriteOnUpdate.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/RewriteOnUpdate.java
new file mode 100644
index 0000000000..5377bda50b
--- /dev/null
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/RewriteOnUpdate.java
@@ -0,0 +1,283 @@
+/*
+ * 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.xml.stream;
+
+import java.util.Locale;
+import java.util.Iterator;
+import java.util.Spliterator;
+import java.util.Spliterators;
+import java.util.stream.Stream;
+import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
+import java.util.stream.StreamSupport;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import org.apache.sis.storage.FeatureSet;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.ReadOnlyStorageException;
+import org.apache.sis.util.collection.BackingStoreException;
+import org.apache.sis.util.ArgumentChecks;
+
+// Branch-dependent imports
+import org.opengis.feature.Feature;
+
+
+/**
+ * Helper class for updating an existing XML file, with no feature type change permitted.
+ * The implementation strategy is to rewrite fully the updated features in a temporary file,
+ * then replaces the source file by the temporary file when ready.
+ *
+ * <p>The {@link #flush()} method should always been invoked before a {@code RewriteOnUpdate}
+ * reference is lost, otherwise data may be lost.</p>
+ *
+ * <h2>Multi-threading</h2>
+ * This class is not synchronized for multi-threading. Synchronization is caller's responsibility,
+ * because the caller usually needs to take in account other data store operations such as reads.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.3
+ * @since   1.3
+ * @module
+ */
+public abstract class RewriteOnUpdate implements AutoCloseable {
+    /**
+     * The set of features to update. This is the set specified at construction time.
+     */
+    protected final FeatureSet source;
+
+    /**
+     * The main file, or {@code null} if unknown.
+     */
+    private final Path location;
+
+    /**
+     * Whether the store is initially empty.
+     * It may be the underlying file does not exist or has a length of zero.
+     */
+    private boolean isSourceEmpty;
+
+    /**
+     * The features to write, fetched when first needed.
+     *
+     * @see #filtered()
+     */
+    private Stream<? extends Feature> filtered;
+
+    /**
+     * Creates an updater for the given source of features.
+     *
+     * @param  source    the set of features to update.
+     * @param  location  the main file, or {@code null} if unknown.
+     * @throws IOException if an error occurred while determining whether the file is empty.
+     */
+    public RewriteOnUpdate(final FeatureSet source, final Path location) throws IOException {
+        this.source   = source;
+        this.location = location;
+        isSourceEmpty = (location == null) || Files.notExists(location) || Files.size(location) == 0;
+    }
+
+    /**
+     * Returns the locale to use for locale-sensitive data, or {@code null} if unspecified.
+     * This is <strong>not</strong> for logging or warning messages.
+     *
+     * @return the data locale, or {@code null}.
+     */
+    protected final Locale getLocale() {
+        return (source instanceof StaxDataStore) ? ((StaxDataStore) source).locale : null;
+    }
+
+    /**
+     * Returns {@code true} if there is currently no data.
+     */
+    private boolean isEmpty() throws ReadOnlyStorageException {
+        if (isSourceEmpty) {
+            return filtered == null;
+        } else if (location != null) {
+            return false;
+        } else {
+            throw new ReadOnlyStorageException();
+        }
+    }
+
+    /**
+     * Returns the features to write.
+     *
+     * @throws DataStoreException if the feature stream cannot be obtained.
+     */
+    private Stream<? extends Feature> filtered() throws DataStoreException {
+        if (filtered == null) {
+            filtered = features();
+        }
+        return filtered;
+    }
+
+    /**
+     * Returns the stream of features to copy.
+     * The default implementation delegates to {@link FeatureSet#features(boolean)}.
+     *
+     * @return all features contained in the dataset.
+     * @throws DataStoreException if an error occurred while fetching the features.
+     */
+    protected Stream<? extends Feature> features() throws DataStoreException {
+        return source.features(false);
+    }
+
+    /**
+     * Appends new feature instances in the {@code FeatureSet}.
+     * Any feature already present in the {@link FeatureSet} will remain unmodified.
+     *
+     * @param  features  feature instances to append in the {@code FeatureSet}.
+     * @throws DataStoreException if the feature stream cannot be obtained or updated.
+     */
+    public void add(final Iterator<? extends Feature> features) throws DataStoreException {
+        ArgumentChecks.ensureNonNull("features", features);
+        final Stream<? extends Feature> toAdd = StreamSupport.stream(
+                Spliterators.spliteratorUnknownSize(features, Spliterator.ORDERED), false);
+        if (isEmpty()) {
+            filtered = toAdd;
+        } else {
+            filtered = Stream.concat(filtered(), toAdd);
+        }
+    }
+
+    /**
+     * Removes all feature instances from the {@code FeatureSet} which matches the given predicate.
+     *
+     * @param  filter  a predicate which returns {@code true} for feature instances to be removed.
+     * @return {@code true} if any elements were removed.
+     * @throws DataStoreException if the feature stream cannot be obtained or updated.
+     */
+    public boolean removeIf(final Predicate<? super Feature> filter) throws DataStoreException {
+        ArgumentChecks.ensureNonNull("filter", filter);
+        if (isEmpty()) {
+            return false;
+        }
+        filtered = filtered().filter((feature) -> {
+            boolean r = filter.test(feature);
+            if (r) modified = true;
+            return !r;
+        });
+        modified = false;
+        flush();            // Need immediate execution for getting the boolean value.
+        return modified;
+    }
+
+    /**
+     * A flag telling whether {@link #removeIf(Predicate)} removed at least one feature.
+     */
+    private boolean modified;
+
+    /**
+     * Updates all feature instances from the {@code FeatureSet} which match the given predicate.
+     * If the given operator returns {@code null}, then the filtered feature is removed.
+     *
+     * @param  filter   a predicate which returns {@code true} for feature instances to be updated.
+     * @param  updater  operation called for each matching {@link Feature} instance. May return {@code null}.
+     * @throws DataStoreException if the feature stream cannot be obtained or updated.
+     */
+    public void replaceIf(final Predicate<? super Feature> filter, final UnaryOperator<Feature> updater) throws DataStoreException {
+        ArgumentChecks.ensureNonNull("filter",  filter);
+        ArgumentChecks.ensureNonNull("updater", updater);
+        if (!isEmpty()) {
+            filtered = filtered().map((feature) -> (feature != null) && filter.test(feature) ? updater.apply(feature) : feature);
+        }
+    }
+
+    /**
+     * Creates an initially empty temporary file.
+     *
+     * @return the temporary file.
+     * @throws IOException if an error occurred while creating the temporary file.
+     */
+    protected abstract Path createTemporaryFile() throws IOException;
+
+    /**
+     * Creates a new XML document writer for an output in the specified temporary file.
+     * Caller is responsible for closing the writer.
+     *
+     * @param  temporary  the temporary stream where to write, or {@code null} for writing directly in the store file.
+     * @return the writer where to copy updated features.
+     * @throws Exception if an error occurred while creating the writer.
+     *         May be {@link DataStoreException}, {@link IOException}, {@link RuntimeException}, <i>etc.</i>
+     */
+    protected abstract StaxStreamWriter createWriter(OutputStream temporary) throws Exception;
+
+    /**
+     * Writes immediately all feature instances.
+     * This method does nothing if there is no data to write.
+     *
+     * @throws DataStoreException if an error occurred.
+     */
+    public void flush() throws DataStoreException {
+        try (Stream<? extends Feature> content = filtered) {
+            if (content != null) {
+                filtered = null;
+                OutputStream temporary = null;
+                Path target = isSourceEmpty ? null : createTemporaryFile();
+                try {
+                    if (target != null) {
+                        temporary = Files.newOutputStream(target);
+                    }
+                    try (StaxStreamWriter writer = createWriter(temporary)) {
+                        temporary = null;       // Stream will be closed by writer.
+                        isSourceEmpty = false;
+                        writer.writeStartDocument();
+                        content.sequential().forEachOrdered(writer);
+                        writer.writeEndDocument();
+                    }
+                    if (target != null) {
+                        Files.move(target, location, StandardCopyOption.REPLACE_EXISTING);
+                        target = null;
+                    }
+                } finally {
+                    if (temporary != null) temporary.close();
+                    if (target != null) Files.delete(target);       // Delete the temporary file if an error occurred.
+                }
+            }
+        } catch (DataStoreException e) {
+            throw e;
+        } catch (BackingStoreException e) {
+            final Throwable cause = e.getCause();
+            if (cause instanceof DataStoreException) {
+                throw (DataStoreException) cause;
+            }
+            throw new DataStoreException(e.getLocalizedMessage(), cause);
+        } catch (Exception e) {
+            if (e instanceof UncheckedIOException) {
+                e = ((UncheckedIOException) e).getCause();
+            }
+            throw new DataStoreException(e);
+        }
+    }
+
+    /**
+     * Releases resources used by this updater. If {@link #flush()} has not been invoked, data may be lost.
+     * This method is useful in try-with-resource in case something fails before {@link #flush()} invocation.
+     */
+    @Override
+    public void close() {
+        final Stream<? extends Feature> content = filtered;
+        if (content != null) {
+            filtered = null;
+            content.close();
+        }
+    }
+}
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/StaxDataStore.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/StaxDataStore.java
index 55cd4d4b32..3901ea351a 100644
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/StaxDataStore.java
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/StaxDataStore.java
@@ -24,6 +24,7 @@ import java.util.logging.Filter;
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
+import java.io.OutputStream;
 import java.nio.file.Path;
 import java.nio.charset.Charset;
 import java.nio.file.StandardOpenOption;
@@ -60,7 +61,7 @@ import org.apache.sis.storage.UnsupportedStorageException;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.3
  * @since   0.8
  * @module
  */
@@ -395,7 +396,7 @@ public abstract class StaxDataStore extends URIDataStore {
     /**
      * Returns the factory for StAX writers. The same instance is returned for all {@code StaxDataStore} lifetime.
      *
-     * <p>This method is indirectly invoked by {@link #createWriter(StaxStreamWriter)},
+     * <p>This method is indirectly invoked by {@link #createWriter(StaxStreamWriter, Object)},
      * through a call to {@link OutputType#create(StaxDataStore, Object)}.</p>
      */
     final XMLOutputFactory outputFactory() {
@@ -515,55 +516,65 @@ public abstract class StaxDataStore extends URIDataStore {
      * whether this method will succeed in creating a new writer depends on the storage type
      * (e.g. file or output stream).
      *
-     * @param  target  the writer which will store the {@code XMLStreamWriter} reference.
+     * @param  target     the writer which will store the {@code XMLStreamWriter} reference.
+     * @param  temporary  the temporary stream where to write, or {@code null} for the main storage.
      * @return a new writer for writing the XML data.
      * @throws DataStoreException if the output type is not recognized or the data store is closed.
      * @throws XMLStreamException if an error occurred while opening the XML file.
      * @throws IOException if an error occurred while preparing the output stream.
      */
-    final synchronized XMLStreamWriter createWriter(final StaxStreamWriter target)
+    final synchronized XMLStreamWriter createWriter(final StaxStreamWriter target, final OutputStream temporary)
             throws DataStoreException, XMLStreamException, IOException
     {
-        Object outputOrFile = storage;
-        if (outputOrFile == null) {
-            throw new DataStoreClosedException(getLocale(), getFormatName(), StandardOpenOption.WRITE);
-        }
-        switch (state) {
-            default:       throw new AssertionError(state);
-            case READING:  throw new ConcurrentReadException (getLocale(), getDisplayName());
-            case WRITING:  throw new ConcurrentWriteException(getLocale(), getDisplayName());
-            case START:    break;         // Stream already at the data start; nothing to do.
-            case FINISHED: {
-                if (reset()) break;
-                throw new ForwardOnlyStorageException(getLocale(), getDisplayName(), StandardOpenOption.WRITE);
+        AutoCloseable output;
+        Object outputOrFile;
+        OutputType outputType;
+        if (temporary == null) {
+            output       = stream;
+            outputOrFile = storage;
+            outputType   = storageToWriter;
+            if (outputOrFile == null) {
+                throw new DataStoreClosedException(getLocale(), getFormatName(), StandardOpenOption.WRITE);
             }
-        }
-        /*
-         * If the storage given by the user was not one of OutputStream, Writer or other type recognized
-         * by OutputType, then maybe that storage was a Path, File or URL, in which case the constructor
-         * should have opened an InputStream (not an OutputStream) for it. In some cases (e.g. reading a
-         * channel opened on a file), the input stream can be converted to an output stream.
-         */
-        AutoCloseable output = stream;
-        OutputType type = storageToWriter;
-        if (type == null) {
-            type   = OutputType.STREAM;
-            output = IOUtilities.toOutputStream(output);
-            if (output == null) {
-                throw new UnsupportedStorageException(getLocale(), getFormatName(), storage, StandardOpenOption.WRITE);
+            switch (state) {
+                default:       throw new AssertionError(state);
+                case READING:  throw new ConcurrentReadException (getLocale(), getDisplayName());
+                case WRITING:  throw new ConcurrentWriteException(getLocale(), getDisplayName());
+                case START:    break;         // Stream already at the data start; nothing to do.
+                case FINISHED: {
+                    if (reset()) break;
+                    throw new ForwardOnlyStorageException(getLocale(), getDisplayName(), StandardOpenOption.WRITE);
+                }
             }
-            outputOrFile = output;
-            if (output != stream) {
-                stream = output;
-                mark();
+            /*
+             * If the storage given by the user was not one of OutputStream, Writer or other type recognized
+             * by OutputType, then maybe that storage was a Path, File or URL, in which case the constructor
+             * should have opened an InputStream (not an OutputStream) for it. In some cases (e.g. reading a
+             * channel opened on a file), the input stream can be converted to an output stream.
+             */
+            if (outputType == null) {
+                outputType = OutputType.STREAM;
+                outputOrFile = output = IOUtilities.toOutputStream(output);
+                if (output == null) {
+                    throw new UnsupportedStorageException(getLocale(), getFormatName(), outputOrFile, StandardOpenOption.WRITE);
+                }
+                if (output != stream) {
+                    stream = output;
+                    mark();
+                }
             }
+        } else {
+            outputType = OutputType.STREAM;
+            outputOrFile = output = temporary;
         }
-        XMLStreamWriter writer = type.create(this, outputOrFile);
+        XMLStreamWriter writer = outputType.create(this, outputOrFile);
         if (indentation >= 0) {
             writer = new FormattedWriter(writer, indentation);
         }
         target.stream = output;
-        state = WRITING;
+        if (temporary == null) {
+            state = WRITING;
+        }
         return writer;
     }
 
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/StaxStreamWriter.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/StaxStreamWriter.java
index 701812d20b..ece5ea44a5 100644
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/StaxStreamWriter.java
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/StaxStreamWriter.java
@@ -20,6 +20,7 @@ import java.util.Map;
 import java.util.Date;
 import java.util.function.Consumer;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.io.UncheckedIOException;
 import java.nio.charset.Charset;
 import javax.xml.namespace.QName;
@@ -85,7 +86,7 @@ import org.opengis.feature.Feature;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 0.8
+ * @version 1.3
  * @since   0.8
  * @module
  */
@@ -106,15 +107,18 @@ public abstract class StaxStreamWriter extends StaxStreamIO implements Consumer<
     /**
      * Creates a new XML writer for the given data store.
      *
-     * @param  owner  the data store for which this writer is created.
+     * @param  owner      the data store for which this writer is created.
+     * @param  temporary  the temporary stream where to write, or {@code null} for the main storage.
      * @throws DataStoreException if the output type is not recognized or the data store is closed.
      * @throws XMLStreamException if an error occurred while opening the XML file.
      * @throws IOException if an error occurred while preparing the output stream.
      */
     @SuppressWarnings("ThisEscapedInObjectConstruction")
-    protected StaxStreamWriter(final StaxDataStore owner) throws DataStoreException, XMLStreamException, IOException {
+    protected StaxStreamWriter(final StaxDataStore owner, final OutputStream temporary)
+            throws DataStoreException, XMLStreamException, IOException
+    {
         super(owner);
-        writer = owner.createWriter(this);      // Okay because will not store the 'this' reference.
+        writer = owner.createWriter(this, temporary);      // Okay because will not store the `this` reference.
     }
 
     /**
diff --git a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/package-info.java b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/package-info.java
index ac07cc8613..20f295c0f6 100644
--- a/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/package-info.java
+++ b/storage/sis-xmlstore/src/main/java/org/apache/sis/internal/storage/xml/stream/package-info.java
@@ -24,7 +24,7 @@
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.0
+ * @version 1.3
  * @since   0.8
  * @module
  */
diff --git a/storage/sis-xmlstore/src/test/java/org/apache/sis/internal/storage/gpx/UpdaterTest.java b/storage/sis-xmlstore/src/test/java/org/apache/sis/internal/storage/gpx/UpdaterTest.java
new file mode 100644
index 0000000000..b951f5973e
--- /dev/null
+++ b/storage/sis-xmlstore/src/test/java/org/apache/sis/internal/storage/gpx/UpdaterTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.Arrays;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.time.Instant;
+import com.esri.core.geometry.Point;
+import java.io.InputStream;
+import java.nio.file.StandardCopyOption;
+import org.apache.sis.setup.GeometryLibrary;
+import org.apache.sis.setup.OptionKey;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.StorageConnector;
+import org.apache.sis.test.DependsOn;
+import org.apache.sis.test.TestCase;
+import org.junit.BeforeClass;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.After;
+import org.junit.Test;
+
+import static org.apache.sis.test.MetadataAssert.*;
+
+// Branch-dependent imports
+import org.opengis.feature.Feature;
+
+
+/**
+ * Tests (indirectly) the {@link Updater} class.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.3
+ *
+ * @see <a href="https://issues.apache.org/jira/browse/SIS-411">SIS-411</a>
+ *
+ * @since 1.3
+ * @module
+ */
+@DependsOn(WriterTest.class)
+public final strictfp class UpdaterTest extends TestCase {
+    /**
+     * The provider shared by all data stores created in this test class.
+     */
+    private static StoreProvider provider;
+
+    /**
+     * Creates the provider to be shared by all data stores created in this test class.
+     */
+    @BeforeClass
+    public static void createProvider() {
+        provider = new StoreProvider();
+    }
+
+    /**
+     * Disposes the data store provider after all tests have been completed.
+     */
+    @AfterClass
+    public static void disposeProvider() {
+        provider = null;
+    }
+
+    /**
+     * Temporary file where to write the GPX file.
+     */
+    private Path file;
+
+    /**
+     * Creates the temporary file before test execution.
+     *
+     * @throws IOException if the temporary file cannot be created.
+     */
+    @Before
+    public void createTemporaryFile() throws IOException {
+        file = Files.createTempFile("GPX", ".xml");
+    }
+
+    /**
+     * Deletes temporary file after test execution.
+     *
+     * @throws IOException if the temporary file cannot be deleted.
+     */
+    @After
+    public void deleteTemporaryFile() throws IOException {
+        if (file != null) {
+            Files.delete(file);
+        }
+    }
+
+    /**
+     * Creates a new GPX data store which will read and write in a temporary file.
+     */
+    private Store create() throws DataStoreException, IOException {
+        final StorageConnector connector = new StorageConnector(file);
+        connector.setOption(OptionKey.GEOMETRY_LIBRARY, GeometryLibrary.ESRI);
+        connector.setOption(OptionKey.OPEN_OPTIONS, new StandardOpenOption[] {
+                            StandardOpenOption.READ, StandardOpenOption.WRITE});
+        return new Store(provider, connector);
+    }
+
+    /**
+     * Tests writing in an initially empty file.
+     *
+     * @throws IOException if an error occurred while creating the temporary file.
+     * @throws DataStoreException if an error occurred while using the GPX store.
+     */
+    @Test
+    public void testWriteEmpty() throws DataStoreException, IOException {
+        try (final Store store = create()) {
+            final Types types = store.types;
+            final Feature point1 = types.wayPoint.newInstance();
+            final Feature point2 = types.wayPoint.newInstance();
+            final Feature point3 = types.wayPoint.newInstance();
+            point1.setPropertyValue("sis:geometry", new Point(15, 10));
+            point2.setPropertyValue("sis:geometry", new Point(25, 20));
+            point3.setPropertyValue("sis:geometry", new Point(35, 30));
+            point1.setPropertyValue("time", Instant.parse("2010-01-10T00:00:00Z"));
+            point3.setPropertyValue("time", Instant.parse("2010-01-30T00:00:00Z"));
+            store.add(Arrays.asList(point1, point2, point3).iterator());
+        }
+        assertXmlEquals(
+                "<gpx xmlns=\"" + Tags.NAMESPACE + "1/1\" version=\"1.1\">\n" +
+                "  <wpt lat=\"10.0\" lon=\"15.0\">\n" +
+                "    <time>2010-01-10T00:00:00Z</time>\n" +
+                "  </wpt>\n" +
+                "  <wpt lat=\"20.0\" lon=\"25.0\"/>\n" +
+                "  <wpt lat=\"30.0\" lon=\"35.0\">\n" +
+                "    <time>2010-01-30T00:00:00Z</time>\n" +
+                "  </wpt>\n" +
+                "</gpx>", file, "xmlns:*");
+    }
+
+    /**
+     * Tests an update which requires rewriting the XML file.
+     *
+     * @throws IOException if an error occurred while creating the temporary file.
+     * @throws DataStoreException if an error occurred while using the GPX store.
+     */
+    @Test
+    public void testRewrite() throws DataStoreException, IOException {
+        try (InputStream in = UpdaterTest.class.getResourceAsStream("1.1/waypoint.xml")) {
+            Files.copy(in, file, StandardCopyOption.REPLACE_EXISTING);
+        }
+        assertTrue(containsLat20());
+        final boolean result;
+        try (final Store store = create()) {
+            result = store.removeIf((feature) -> {
+                Object point = feature.getPropertyValue("sis:geometry");
+                return ((Point) point).getY() == 20;
+            });
+        }
+        assertTrue(result);
+        assertFalse(containsLat20());
+    }
+
+    /**
+     * Returns whether the temporary file contains the {@code lat="20"} string.
+     */
+    private boolean containsLat20() throws IOException {
+        return org.apache.sis.internal.jdk9.JDK9.readString(file).contains("lat=\"20");     // May have trailing ".0".
+    }
+}
diff --git a/storage/sis-xmlstore/src/test/java/org/apache/sis/test/suite/GPXTestSuite.java b/storage/sis-xmlstore/src/test/java/org/apache/sis/test/suite/GPXTestSuite.java
index e3ce9d97c7..f72d8132e3 100644
--- a/storage/sis-xmlstore/src/test/java/org/apache/sis/test/suite/GPXTestSuite.java
+++ b/storage/sis-xmlstore/src/test/java/org/apache/sis/test/suite/GPXTestSuite.java
@@ -28,7 +28,8 @@ import org.junit.BeforeClass;
     org.apache.sis.internal.storage.gpx.TypesTest.class,
     org.apache.sis.internal.storage.gpx.MetadataTest.class,
     org.apache.sis.internal.storage.gpx.ReaderTest.class,
-    org.apache.sis.internal.storage.gpx.WriterTest.class
+    org.apache.sis.internal.storage.gpx.WriterTest.class,
+    org.apache.sis.internal.storage.gpx.UpdaterTest.class
 })
 public final strictfp class GPXTestSuite extends TestSuite {
     /**