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 <martin.desruisse...@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 { /**