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 c456f0598daa5ce46ea42392556c207b1911f03d
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Thu Dec 7 20:18:34 2023 +0100

    Add an option to complete the metadata of a resource with an auxiliary 
metadata file.
---
 .../org/apache/sis/storage/gpx/WritableStore.java  |   2 +-
 .../main/org/apache/sis/storage/DataOptionKey.java |  18 ++--
 .../apache/sis/storage/base/MetadataBuilder.java   |  57 +++++++++-
 .../org/apache/sis/storage/base/PRJDataStore.java  |  38 +++----
 .../org/apache/sis/storage/base/URIDataStore.java  | 116 ++++++++++++---------
 .../main/org/apache/sis/storage/csv/Store.java     |   1 +
 .../org/apache/sis/storage/csv/StoreProvider.java  |   2 +-
 .../org/apache/sis/storage/esri/RasterStore.java   |   4 +-
 .../apache/sis/storage/esri/RawRasterStore.java    |   3 +-
 .../apache/sis/storage/image/WorldFileStore.java   |  15 +--
 .../apache/sis/storage/image/WritableStore.java    |   7 ++
 .../org/apache/sis/storage/internal/Resources.java |   5 +
 .../sis/storage/internal/Resources.properties      |   1 +
 .../sis/storage/internal/Resources_fr.properties   |   1 +
 .../main/org/apache/sis/storage/wkt/Store.java     |   2 +
 .../main/org/apache/sis/storage/xml/Store.java     |   1 +
 .../main/org/apache/sis/setup/OptionKey.java       |  14 ++-
 .../main/org/apache/sis/setup/package-info.java    |   2 +-
 .../main/org/apache/sis/util/ArraysExt.java        |  83 +++++++++++++--
 .../apache/sis/util/internal/CollectionsExt.java   |  16 +++
 .../test/org/apache/sis/util/ArraysExtTest.java    |  26 +++--
 21 files changed, 298 insertions(+), 116 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.storage.xml/main/org/apache/sis/storage/gpx/WritableStore.java
 
b/endorsed/src/org.apache.sis.storage.xml/main/org/apache/sis/storage/gpx/WritableStore.java
index a0c358419f..71a4317fef 100644
--- 
a/endorsed/src/org.apache.sis.storage.xml/main/org/apache/sis/storage/gpx/WritableStore.java
+++ 
b/endorsed/src/org.apache.sis.storage.xml/main/org/apache/sis/storage/gpx/WritableStore.java
@@ -126,7 +126,7 @@ public final class WritableStore extends Store implements 
WritableFeatureSet {
      */
     private Updater updater() throws DataStoreException {
         try {
-            return new Updater(this, getSpecifiedPath());
+            return new Updater(this, locationAsPath);
         } catch (IOException e) {
             throw new DataStoreException(e);
         }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/DataOptionKey.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/DataOptionKey.java
index 87a642be2c..89ef371110 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/DataOptionKey.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/DataOptionKey.java
@@ -16,7 +16,7 @@
  */
 package org.apache.sis.storage;
 
-import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import java.nio.file.Path;
 import org.apache.sis.setup.OptionKey;
 import org.apache.sis.storage.event.StoreListeners;
 import org.apache.sis.feature.FoliationRepresentation;
@@ -28,7 +28,7 @@ import org.apache.sis.feature.FoliationRepresentation;
  * or other kinds of structure that are specific to some data formats.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.3
+ * @version 1.5
  *
  * @param <T>  the type of option values.
  *
@@ -41,15 +41,15 @@ public final class DataOptionKey<T> extends OptionKey<T> {
     private static final long serialVersionUID = 8927757348322016043L;
 
     /**
-     * The coordinate reference system (CRS) of data to use if not explicitly 
defined.
-     * This option can be used when the file to read does not describe itself 
the data CRS.
-     * For example, this option can be used when reading ASCII Grid without 
CRS information,
-     * but is ignored if the ASCII Grid file is accompanied by a {@code *.prj} 
file giving the CRS.
+     * Path to an auxiliary file containing metadata encoded in an ISO 19115-3 
XML document.
+     * The given path, if not absolute, is relative to the path of the main 
storage file.
+     * If the file exists, it is parsed and its content is merged or appended 
after the
+     * metadata read by the storage. If the file does not exist, then it is 
ignored.
      *
-     * @since 1.2
+     * @since 1.5
      */
-    public static final OptionKey<CoordinateReferenceSystem> DEFAULT_CRS =
-            new DataOptionKey<>("DEFAULT_CRS", 
CoordinateReferenceSystem.class);
+    public static final OptionKey<Path> METADATA_PATH =
+            new DataOptionKey<>("METADATA_PATH", Path.class);
 
     /**
      * Whether to assemble trajectory fragments (distinct CSV lines) into a 
single {@code Feature} instance
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
index 2e72d01100..9236535864 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/MetadataBuilder.java
@@ -29,6 +29,7 @@ import java.util.LinkedHashSet;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 import java.net.URI;
 import java.nio.charset.Charset;
 import javax.measure.Unit;
@@ -3302,7 +3303,7 @@ parse:      for (int i = 0; i < length;) {
     }
 
     /**
-     * Merge the given metadata into the metadata created by this builder.
+     * Merges the given metadata into the metadata created by this builder.
      * The given source should be an instance of {@link Metadata},
      * but some types of metadata components are accepted as well.
      *
@@ -3346,9 +3347,63 @@ parse:      for (int i = 0; i < length;) {
         else return false;
         final Merger merger = new Merger(locale);
         merger.copy(source, target);
+        useParentElements();
         return true;
     }
 
+    /**
+     * Replaces any null metadata element by the last element from the parent.
+     * This is used for continuing the edition of an existing metadata.
+     */
+    private void useParentElements() {
+        if (identification == null) identification = last 
(DefaultDataIdentification.class,     metadata,       
Metadata::getIdentificationInfo);
+        if (citation       == null) citation       = 
fetch(DefaultCitation.class,               identification, 
Identification::getCitation);
+        if (responsibility == null) responsibility = last 
(DefaultResponsibility.class,         citation,       
Citation::getCitedResponsibleParties);
+        if (party          == null) party          = last 
(AbstractParty.class,                 responsibility, 
Responsibility::getParties);
+        if (constraints    == null) constraints    = last 
(DefaultLegalConstraints.class,       identification, 
Identification::getResourceConstraints);
+        if (extent         == null) extent         = last 
(DefaultExtent.class,                 identification, 
Identification::getExtents);
+        if (acquisition    == null) acquisition    = last 
(DefaultAcquisitionInformation.class, metadata,       
Metadata::getAcquisitionInformation);
+        if (platform       == null) platform       = last 
(DefaultPlatform.class,               acquisition,    
AcquisitionInformation::getPlatforms);
+    }
+
+    /**
+     * Returns the element of the given source metadata if it is of the 
desired class.
+     * This method is equivalent to {@link #last(Class, Object, Function)} but 
for a singleton.
+     *
+     * @param  target  the desired class.
+     * @param  source  the source metadata, or {@code null} if none.
+     * @param  getter  the getter to use for fetching elements from the source 
metadata.
+     * @return the metadata element from the source, or {@code null} if none.
+     */
+    private static <S,T> T fetch(final Class<T> target, final S source, final 
Function<S,?> getter) {
+        if (source != null) {
+            final Object last = getter.apply(source);
+            if (target.isInstance(last)) {
+                return target.cast(last);
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Returns the element of the given source metadata if it is of the 
desired class.
+     * This method is equivalent to {@link #fetch(Class, Object, Function)} 
but for a collection.
+     *
+     * @param  target  the desired class.
+     * @param  source  the source metadata, or {@code null} if none.
+     * @param  getter  the getter to use for fetching elements from the source 
metadata.
+     * @return the metadata element from the source, or {@code null} if none.
+     */
+    private static <S,T> T last(final Class<T> target, final S source, final 
Function<S,Collection<?>> getter) {
+        if (source != null) {
+            final Object last = CollectionsExt.last(getter.apply(source));
+            if (target.isInstance(last)) {
+                return target.cast(last);
+            }
+        }
+        return null;
+    }
+
     /**
      * Writes all pending metadata objects into the {@link DefaultMetadata} 
root class.
      * Then all {@link #identification}, {@link #gridRepresentation}, 
<i>etc.</i> fields
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/PRJDataStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/PRJDataStore.java
index 2f9d380e0c..5bd4edcebd 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/PRJDataStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/PRJDataStore.java
@@ -39,7 +39,7 @@ import org.opengis.parameter.ParameterDescriptor;
 import org.opengis.parameter.ParameterDescriptorGroup;
 import org.opengis.parameter.ParameterValueGroup;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
-import org.apache.sis.storage.DataOptionKey;
+import org.apache.sis.setup.OptionKey;
 import org.apache.sis.storage.DataStore;
 import org.apache.sis.storage.DataStoreProvider;
 import org.apache.sis.storage.DataStoreException;
@@ -60,13 +60,13 @@ import org.apache.sis.util.resources.Vocabulary;
 
 /**
  * A data store for a file or URI accompanied by an auxiliary file of the same 
name with {@code .prj} extension.
- * If the auxiliary file is absent, {@link DataOptionKey#DEFAULT_CRS} is used 
as a fallback.
+ * If the auxiliary file is absent, {@link OptionKey#DEFAULT_CRS} is used as a 
fallback.
  * The WKT 1 variant used for parsing the {@code "*.prj"} file is the variant 
used by "World Files" and GDAL;
  * this is not the standard specified by OGC 01-009 (they differ in there 
interpretation of units of measurement).
  *
  * <p>It is still possible to create a data store with a {@link 
java.nio.channels.ReadableByteChannel},
  * {@link java.io.InputStream} or {@link java.io.Reader}, in which case the 
{@linkplain #location} will
- * be null and the CRS defined by the {@code DataOptionKey} will be used.</p>
+ * be null and the CRS defined by the {@code OptionKey} will be used.</p>
  *
  * @author  Martin Desruisseaux (Geomatys)
  */
@@ -105,7 +105,7 @@ public abstract class PRJDataStore extends URIDataStore {
     private final TimeZone timezone;
 
     /**
-     * The coordinate reference system. This is initialized on the value 
provided by {@link DataOptionKey#DEFAULT_CRS}
+     * The coordinate reference system. This is initialized on the value 
provided by {@link OptionKey#DEFAULT_CRS}
      * at construction time, and is modified later if a {@code "*.prj"} file 
is found.
      */
     protected CoordinateReferenceSystem crs;
@@ -116,10 +116,10 @@ public abstract class PRJDataStore extends URIDataStore {
      *
      * <p>The following options are recognized:</p>
      * <ul>
-     *   <li>{@link DataOptionKey#DEFAULT_CRS}: default CRS if no auxiliary 
{@code "*.prj"} file is found.</li>
-     *   <li>{@link DataOptionKey#ENCODING}: encoding of the {@code "*.prj"} 
file. Default is the JVM default.</li>
-     *   <li>{@link DataOptionKey#TIMEZONE}: timezone of dates in the {@code 
"*.prj"} file. Default is UTC.</li>
-     *   <li>{@link DataOptionKey#LOCALE}: locale for texts in the {@code 
"*.prj"} file. Default is English.</li>
+     *   <li>{@link OptionKey#DEFAULT_CRS}: default CRS if no auxiliary {@code 
"*.prj"} file is found.</li>
+     *   <li>{@link OptionKey#ENCODING}: encoding of the {@code "*.prj"} file. 
Default is the JVM default.</li>
+     *   <li>{@link OptionKey#TIMEZONE}: timezone of dates in the {@code 
"*.prj"} file. Default is UTC.</li>
+     *   <li>{@link OptionKey#LOCALE}: locale for texts in the {@code "*.prj"} 
file. Default is English.</li>
      * </ul>
      *
      * @param  provider   the factory that created this {@code PRJDataStore} 
instance, or {@code null} if unspecified.
@@ -128,10 +128,10 @@ public abstract class PRJDataStore extends URIDataStore {
      */
     protected PRJDataStore(final DataStoreProvider provider, final 
StorageConnector connector) throws DataStoreException {
         super(provider, connector);
-        crs      = connector.getOption(DataOptionKey.DEFAULT_CRS);
-        encoding = connector.getOption(DataOptionKey.ENCODING);
-        locale   = connector.getOption(DataOptionKey.LOCALE);       // For 
`InternationalString`, not for numbers.
-        timezone = connector.getOption(DataOptionKey.TIMEZONE);
+        crs      = connector.getOption(OptionKey.DEFAULT_CRS);
+        encoding = connector.getOption(OptionKey.ENCODING);
+        locale   = connector.getOption(OptionKey.LOCALE);       // For 
`InternationalString`, not for numbers.
+        timezone = connector.getOption(OptionKey.TIMEZONE);
     }
 
     /**
@@ -148,7 +148,7 @@ public abstract class PRJDataStore extends URIDataStore {
         try {
             final AuxiliaryContent content = readAuxiliaryFile(PRJ);
             if (content == null) {
-                
listeners.warning(Resources.format(Resources.Keys.CanNotReadAuxiliaryFile_1, 
PRJ));
+                listeners.warning(cannotReadAuxiliaryFile(PRJ));
                 return;
             }
             final String wkt = content.toString();
@@ -166,12 +166,12 @@ public abstract class PRJDataStore extends URIDataStore {
                 return;
             }
         } catch (NoSuchFileException | FileNotFoundException e) {
-            
listeners.warning(Resources.format(Resources.Keys.CanNotReadAuxiliaryFile_1, 
PRJ), e);
+            listeners.warning(cannotReadAuxiliaryFile(PRJ), e);
             return;
         } catch (IOException | ParseException | ClassCastException e) {
             cause = e;
         }
-        throw new 
DataStoreReferencingException(Resources.format(Resources.Keys.CanNotReadAuxiliaryFile_1,
 PRJ), cause);
+        throw new DataStoreReferencingException(cannotReadAuxiliaryFile(PRJ), 
cause);
     }
 
     /**
@@ -196,7 +196,7 @@ public abstract class PRJDataStore extends URIDataStore {
          * and URL does not open S3 files in current implementation.
          */
         final InputStream stream;
-        Path path = getSpecifiedPath();
+        Path path = locationAsPath;
         final Object source;                    // In case an error message is 
produced.
         if (path != null) {
             final String base = getBaseFilename(path);
@@ -212,7 +212,7 @@ public abstract class PRJDataStore extends URIDataStore {
             stream = url.openStream();
             source = url;
         } catch (URISyntaxException e) {
-            throw new 
DataStoreException(Resources.format(Resources.Keys.CanNotReadAuxiliaryFile_1, 
"*." + extension), e);
+            throw new DataStoreException(cannotReadAuxiliaryFile(extension), 
e);
         }
         /*
          * Reads the auxiliary file fully, with an arbitrary size limit.
@@ -495,7 +495,7 @@ public abstract class PRJDataStore extends URIDataStore {
          */
         @Override
         protected ParameterDescriptorGroup build(final ParameterBuilder 
builder) {
-            return builder.createGroup(LOCATION_PARAM, DEFAULT_CRS);
+            return builder.createGroup(LOCATION_PARAM, METADATA_PARAM, 
DEFAULT_CRS);
         }
 
         /**
@@ -509,7 +509,7 @@ public abstract class PRJDataStore extends URIDataStore {
             ArgumentChecks.ensureNonNull("parameter", parameters);
             final StorageConnector connector = connector(this, parameters);
             final Parameters pg = Parameters.castOrWrap(parameters);
-            connector.setOption(DataOptionKey.DEFAULT_CRS, 
pg.getValue(DEFAULT_CRS));
+            connector.setOption(OptionKey.DEFAULT_CRS, 
pg.getValue(DEFAULT_CRS));
             return open(connector);
         }
     }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStore.java
index f59126c0d3..df7f1141ae 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/base/URIDataStore.java
@@ -21,14 +21,14 @@ import java.io.DataInput;
 import java.io.DataOutput;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.io.File;
+import java.io.IOException;
 import java.net.URI;
 import java.nio.file.Path;
 import java.nio.file.Files;
 import java.nio.file.OpenOption;
 import java.nio.file.StandardOpenOption;
-import java.nio.file.FileSystemNotFoundException;
 import java.nio.charset.Charset;
+import jakarta.xml.bind.JAXBException;
 import org.opengis.util.GenericName;
 import org.opengis.parameter.ParameterValueGroup;
 import org.opengis.parameter.ParameterDescriptor;
@@ -36,6 +36,7 @@ import org.opengis.parameter.ParameterDescriptorGroup;
 import org.opengis.parameter.ParameterNotFoundException;
 import org.apache.sis.parameter.ParameterBuilder;
 import org.apache.sis.storage.StorageConnector;
+import org.apache.sis.storage.DataOptionKey;
 import org.apache.sis.storage.Resource;
 import org.apache.sis.storage.DataStore;
 import org.apache.sis.storage.DataStoreProvider;
@@ -47,6 +48,7 @@ import org.apache.sis.setup.OptionKey;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.iso.Names;
 import org.apache.sis.util.logging.Logging;
+import org.apache.sis.xml.XML;
 
 
 /**
@@ -64,23 +66,17 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
     protected final URI location;
 
     /**
-     * The {@link #location} as a path, computed when first needed.
-     * If the storage given at construction time was a {@link Path} or a 
{@link File} instance,
-     * then this field is initialized in the constructor in order to avoid a 
"path → URI → path" roundtrip
-     * (such roundtrip transforms relative paths into {@linkplain 
Path#toAbsolutePath() absolute paths}).
+     * The {@link #location} as a path, or {@code null} if none or if the URI 
cannot be converted to a path.
      *
-     * @see #getSpecifiedPath()
      * @see #getComponentFiles()
      */
-    private volatile Path locationAsPath;
+    protected final Path locationAsPath;
 
     /**
-     * Whether {@link #locationAsPath} was initialized at construction time 
({@code true})
-     * of inferred from the {@link #location} URI at a later time ({@code 
false}).
-     *
-     * @see #getSpecifiedPath()
+     * Path to an auxiliary file providing metadata as path, or {@code null} 
if none or not applicable.
+     * Unless absolute, this path is relative to the {@link #location} or to 
the {@link #locationAsPath}.
      */
-    private final boolean locationIsPath;
+    private final Path metadataPath;
 
     /**
      * Creates a new data store. This constructor does not open the file,
@@ -94,16 +90,13 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
      */
     protected URIDataStore(final DataStoreProvider provider, final 
StorageConnector connector) throws DataStoreException {
         super(provider, connector);
-        location = connector.getStorageAs(URI.class);
-        final Object storage = connector.getStorage();
-        if (storage instanceof Path) {
-            locationAsPath = (Path) storage;
-        } else if (storage instanceof File) {
-            locationAsPath = ((File) storage).toPath();
-        } else if (storage instanceof CharSequence) {
-            locationAsPath = connector.getStorageAs(Path.class);
+        location       = connector.getStorageAs(URI.class);
+        locationAsPath = connector.getStorageAs(Path.class);
+        if (locationAsPath != null || location != null) {
+            metadataPath = connector.getOption(DataOptionKey.METADATA_PATH);
+        } else {
+            metadataPath = null;
         }
-        locationIsPath = (locationAsPath != null);
     }
 
     /**
@@ -141,38 +134,31 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
     }
 
     /**
-     * If the location was specified as a {@link Path} or {@link File} 
instance, returns that path.
-     * Otherwise returns {@code null}. This method does not try to convert URI 
to {@link Path}
-     * because this conversion may fail for HTTP and FTP connections.
-     *
-     * @return the path specified at construction time, or {@code null} if the 
storage was not specified as a path.
+     * Returns the path to the auxiliary metadata file, or {@code null} if 
none.
+     * This is a path build from the "metadata path" option if present.
      */
-    protected final Path getSpecifiedPath() {
-        return locationIsPath ? locationAsPath : null;
+    private Path getMetadataPath() {
+        if (metadataPath != null && locationAsPath != null) {
+            Path path = locationAsPath.getParent();
+            if (path != null) {
+                return path.resolve(metadataPath);
+            }
+        }
+        return null;
     }
 
     /**
-     * Returns the {@linkplain #location} as a {@code Path} component or an 
empty array if none.
-     * The default implementation returns the storage specified at 
construction time if it was
-     * a {@link Path} or {@link File}, or converts the URI to a {@link Path} 
otherwise.
+     * Returns the main and metadata locations as {@code Path} components, or 
an empty array if none.
+     * The default implementation returns the storage specified at 
construction time converted to a {@link Path}
+     * if such conversion was possible, or {@code null} otherwise.
      *
      * @return the URI as a path, or an empty array if unknown.
      * @throws DataStoreException if the URI cannot be converted to a {@link 
Path}.
      */
     @Override
     public Path[] getComponentFiles() throws DataStoreException {
-        Path path = locationAsPath;
-        if (path == null) {
-            if (location == null) {
-                return new Path[0];
-            } else try {
-                path = Path.of(location);
-            } catch (IllegalArgumentException | FileSystemNotFoundException e) 
{
-                throw new DataStoreException(e);
-            }
-            locationAsPath = path;
-        }
-        return new Path[] {path};
+        final var paths = new Path[] {locationAsPath, getMetadataPath()};
+        return ArraysExt.resize(paths, ArraysExt.removeDuplicated(paths, 
ArraysExt.removeNulls(paths)));
     }
 
     /**
@@ -216,6 +202,11 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
          */
         public static final ParameterDescriptor<URI> LOCATION_PARAM;
 
+        /**
+         * Description of the "metadata" parameter.
+         */
+        public static final ParameterDescriptor<Path> METADATA_PARAM;
+
         /**
          * Description of the optional {@value #CREATE} parameter, which may 
be present in writable data store.
          * This parameter is not included in the descriptor created by {@link 
#build(ParameterBuilder)} default
@@ -233,6 +224,7 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
             final ParameterBuilder builder = new ParameterBuilder();
             ENCODING       = 
builder.addName("encoding").setDescription(Resources.formatInternational(Resources.Keys.DataStoreEncoding)).create(Charset.class,
 null);
             CREATE_PARAM   = builder.addName( CREATE   
).setDescription(Resources.formatInternational(Resources.Keys.DataStoreCreate  
)).create(Boolean.class, null);
+            METADATA_PARAM = 
builder.addName("metadata").setDescription(Resources.formatInternational(Resources.Keys.MetadataLocation
 )).create(Path.class, null);
             LOCATION_PARAM = builder.addName( LOCATION 
).setDescription(Resources.formatInternational(Resources.Keys.DataStoreLocation)).setRequired(true).create(URI.class,
 null);
         }
 
@@ -267,14 +259,14 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
         /**
          * Invoked by {@link #getOpenParameters()} the first time that a 
parameter descriptor needs to be created.
          * When invoked, the parameter group name is set to a name derived 
from the {@link #getShortName()} value.
-         * The default implementation creates a group containing only {@link 
#LOCATION_PARAM}.
+         * The default implementation creates a group containing {@link 
#LOCATION_PARAM} and {@link #METADATA_PARAM}.
          * Subclasses can override if they need to create a group with more 
parameters.
          *
          * @param  builder  the builder to use for creating parameter 
descriptor. The group name is already set.
          * @return the parameters descriptor created from the given builder.
          */
         protected ParameterDescriptorGroup build(final ParameterBuilder 
builder) {
-            return builder.createGroup(LOCATION_PARAM);
+            return builder.createGroup(LOCATION_PARAM, METADATA_PARAM);
         }
 
         /**
@@ -390,10 +382,10 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
     }
 
     /**
-     * Adds the filename (without extension) as the citation title if there 
are no titles, or as the identifier otherwise.
+     * Adds the filename (without extension) as the citation title if there is 
no title, or as the identifier otherwise.
      * This method should be invoked last, after {@code DataStore} 
implementation did its best effort for adding a title.
-     * The intent is actually to provide an identifier, but since the title is 
mandatory in ISO 19115 metadata, providing
-     * only an identifier without title would be invalid.
+     * The intend is actually to provide an identifier, but since the title is 
mandatory in ISO 19115 metadata,
+     * providing only an identifier without title would be invalid.
      *
      * @param  builder  where to add the title or identifier.
      */
@@ -403,4 +395,30 @@ public abstract class URIDataStore extends DataStore 
implements StoreResource, R
             builder.addTitleOrIdentifier(filename, MetadataBuilder.Scope.ALL);
         }
     }
+
+    /**
+     * If an auxiliary metadata file has been specified, merge that file to 
the given metadata.
+     * This step should be done only after the data store added its own 
metadata.
+     * Failure to load auxiliary metadata are only a warning.
+     *
+     * @param  builder  where to merge the metadata.
+     */
+    protected final void mergeAuxiliaryMetadata(final MetadataBuilder builder) 
{
+        final Path path = getMetadataPath();
+        if (path != null) try {
+            builder.mergeMetadata(XML.unmarshal(path), getLocale());
+        } catch (JAXBException e) {
+            final Throwable cause = e.getCause();
+            listeners.warning(cannotReadAuxiliaryFile("xml"), (cause 
instanceof IOException) ? (Exception) cause : e);
+        }
+    }
+
+    /**
+     * {@return the error message for saying than auxiliary file cannot be 
read}.
+     *
+     * @param  extension  file extension of the auxiliary file, without 
leading dot.
+     */
+    protected final String cannotReadAuxiliaryFile(final String extension) {
+        return 
Resources.forLocale(getLocale()).getString(Resources.Keys.CanNotReadAuxiliaryFile_1,
 extension);
+    }
 }
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/csv/Store.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/csv/Store.java
index b1d5fede5d..02707d4dd7 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/csv/Store.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/csv/Store.java
@@ -642,6 +642,7 @@ final class Store extends URIDataStore implements 
FeatureSet {
             builder.addResourceScope(ScopeCode.FEATURE, null);
             builder.addExtent(envelope, listeners);
             builder.addFeatureType(featureType, -1);
+            mergeAuxiliaryMetadata(builder);
             addTitleOrIdentifier(builder);
             builder.setISOStandards(false);
             metadata = builder.buildAndFreeze();
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/csv/StoreProvider.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/csv/StoreProvider.java
index 455864ab9a..56fcbbcf56 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/csv/StoreProvider.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/csv/StoreProvider.java
@@ -197,7 +197,7 @@ public final class StoreProvider extends 
URIDataStore.Provider {
      */
     @Override
     protected ParameterDescriptorGroup build(final ParameterBuilder builder) {
-        return builder.createGroup(LOCATION_PARAM, ENCODING, FOLIATION);
+        return builder.createGroup(LOCATION_PARAM, METADATA_PARAM, ENCODING, 
FOLIATION);
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java
index 9b8f3cc9dd..9743c7dad7 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RasterStore.java
@@ -46,7 +46,6 @@ import org.apache.sis.coverage.grid.j2d.ColorModelFactory;
 import org.apache.sis.coverage.grid.j2d.ImageUtilities;
 import org.apache.sis.coverage.grid.j2d.ObservableImage;
 import org.apache.sis.coverage.internal.RangeArgument;
-import org.apache.sis.storage.internal.Resources;
 import org.apache.sis.util.CharSequences;
 import org.apache.sis.util.ArraysExt;
 import org.apache.sis.util.internal.UnmodifiableArrayList;
@@ -172,6 +171,7 @@ abstract class RasterStore extends PRJDataStore implements 
GridCoverageResource
                 builder.addNewBand(band);
             }
         }
+        mergeAuxiliaryMetadata(builder);
         addTitleOrIdentifier(builder);
         builder.setISOStandards(false);
         metadata = builder.buildAndFreeze();
@@ -426,7 +426,7 @@ abstract class RasterStore extends PRJDataStore implements 
GridCoverageResource
         if (exception instanceof NoSuchFileException || exception instanceof 
FileNotFoundException) {
             level = Level.FINE;
         }
-        listeners.warning(level, 
Resources.format(Resources.Keys.CanNotReadAuxiliaryFile_1, suffix), exception);
+        listeners.warning(level, cannotReadAuxiliaryFile(suffix), exception);
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RawRasterStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RawRasterStore.java
index 1a5871868f..ddfa2321c0 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RawRasterStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/esri/RawRasterStore.java
@@ -357,8 +357,7 @@ final class RawRasterStore extends RasterStore {
         ByteOrder byteOrder    = ByteOrder.nativeOrder();
         final AuxiliaryContent header = 
readAuxiliaryFile(RawRasterStoreProvider.HDR);
         if (header == null) {
-            throw new DataStoreException(Resources.forLocale(getLocale())
-                    .getString(Resources.Keys.CanNotReadAuxiliaryFile_1, 
RawRasterStoreProvider.HDR));
+            throw new 
DataStoreException(cannotReadAuxiliaryFile(RawRasterStoreProvider.HDR));
         }
         for (CharSequence line : CharSequences.splitOnEOL(header)) {
             final int length   = line.length();
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/WorldFileStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/WorldFileStore.java
index 6da2247bb3..5293f2c541 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/WorldFileStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/WorldFileStore.java
@@ -47,7 +47,6 @@ import org.apache.sis.storage.DataStoreClosedException;
 import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.ReadOnlyStorageException;
 import org.apache.sis.storage.UnsupportedStorageException;
-import org.apache.sis.storage.internal.Resources;
 import org.apache.sis.storage.base.PRJDataStore;
 import org.apache.sis.storage.base.MetadataBuilder;
 import org.apache.sis.referencing.util.j2d.AffineTransform2D;
@@ -357,7 +356,7 @@ loop:   for (int convention=0;; convention++) {
             }
         }
         if (warning != null) {
-            
listeners.warning(resources().getString(Resources.Keys.CanNotReadAuxiliaryFile_1,
 preferred), warning);
+            listeners.warning(cannotReadAuxiliaryFile(preferred), warning);
         }
         return null;
     }
@@ -373,7 +372,7 @@ loop:   for (int convention=0;; convention++) {
     private AffineTransform2D readWorldFile(final String wld) throws 
IOException, DataStoreException {
         final AuxiliaryContent content = readAuxiliaryFile(wld);
         if (content == null) {
-            
listeners.warning(Resources.format(Resources.Keys.CanNotReadAuxiliaryFile_1, 
wld));
+            listeners.warning(cannotReadAuxiliaryFile(wld));
             return null;
         }
         final String         filename = content.getFilename();
@@ -406,18 +405,11 @@ loop:   for (int convention=0;; convention++) {
         return new AffineTransform2D(elements);
     }
 
-    /**
-     * Returns the localized resources for producing warnings or error 
messages.
-     */
-    final Resources resources() {
-        return Resources.forLocale(listeners.getLocale());
-    }
-
     /**
      * Returns the localized resources for producing error messages.
      */
     private Errors errors() {
-        return Errors.getResources(listeners.getLocale());
+        return Errors.getResources(getLocale());
     }
 
     /**
@@ -539,6 +531,7 @@ loop:   for (int convention=0;; convention++) {
             if (gridGeometry.isDefined(GridGeometry.ENVELOPE)) {
                 builder.addExtent(gridGeometry.getEnvelope(), listeners);
             }
+            mergeAuxiliaryMetadata(builder);
             addTitleOrIdentifier(builder);
             builder.setISOStandards(false);
             metadata = builder.buildAndFreeze();
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/WritableStore.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/WritableStore.java
index fb945643d1..ec40e5651b 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/WritableStore.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/image/WritableStore.java
@@ -350,6 +350,13 @@ writeCoeffs:    for (int i=0;; i++) {
                 Resources.Keys.CanNotRemoveResource_2, getDisplayName(), 
label(resource)), cause);
     }
 
+    /**
+     * Returns the localized resources for producing warnings or error 
messages.
+     */
+    final Resources resources() {
+        return Resources.forLocale(getLocale());
+    }
+
     /**
      * Returns a label for the given resource in error messages.
      */
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
index 63506fd42f..b556fd4937 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.java
@@ -307,6 +307,11 @@ public class Resources extends IndexedResourceBundle {
          */
         public static final short MarkNotSupported_1 = 62;
 
+        /**
+         * Relative path to metadata.
+         */
+        public static final short MetadataLocation = 81;
+
         /**
          * Resource “{0}” does not have an identifier.
          */
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
index f76fb8118f..6356fb4950 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources.properties
@@ -69,6 +69,7 @@ InvalidExpression_2               = Invalid or unsupported 
\u201c{1}\u201d expre
 ExceptionInListener_1             = Exception occurred in a listener for 
events of type \u2018{0}\u2019.
 LoadedGridCoverage_6              = Loaded grid coverage between {1} \u2013 
{2} and {3} \u2013 {4} from file \u201c{0}\u201d in {5} seconds.
 MarkNotSupported_1                = Marks are not supported on \u201c{0}\u201d 
stream.
+MetadataLocation                  = Relative path to metadata.
 MissingResourceIdentifier_1       = Resource \u201c{0}\u201d does not have an 
identifier.
 MissingSchemeInURI_1              = Missing scheme in \u201c{0}\u201d URI.
 NoCommonFeatureType               = No feature type is common to all the 
features to aggregate.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
index b950c2eb63..6b742719e3 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/internal/Resources_fr.properties
@@ -74,6 +74,7 @@ InconsistentNameComponents_2      = Les \u00e9l\u00e9ments 
qui composent le nom
 ExceptionInListener_1             = Une exception est survenue dans un capteur 
d\u2019\u00e9v\u00e9nements de type \u2018{0}\u2019.
 LoadedGridCoverage_6              = Lecture d\u2019une couverture de 
donn\u00e9es entre {1} \u2013 {2} et {3} \u2013 {4} \u00e0 partir du fichier 
\u00ab\u202f{0}\u202f\u00bb en {5} secondes.
 MarkNotSupported_1                = Les marques ne sont pas support\u00e9es 
sur le flux \u00ab\u202f{0}\u202f\u00bb.
+MetadataLocation                  = Chemin relatif des m\u00e9ta-donn\u00e9es.
 MissingResourceIdentifier_1       = La ressource \u00ab\u202f{0}\u202f\u00bb 
n\u2019a pas d\u2019identifiant.
 MissingSchemeInURI_1              = Il manque le sch\u00e9ma dans l\u2019URI 
\u00ab\u202f{0}\u202f\u00bb.
 NoCommonFeatureType               = Il n\u2019y a pas de type commun \u00e0 
toutes les entit\u00e9s \u00e0 agr\u00e9ger.
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/Store.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/Store.java
index aa5a54c3dd..1e4a7099a0 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/Store.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/wkt/Store.java
@@ -181,7 +181,9 @@ final class Store extends URIDataStore {
             }
             if (count == 1) {                   // Set the citation title only 
if non-ambiguous.
                 builder.addTitle(name);
+                mergeAuxiliaryMetadata(builder);
             } else {
+                mergeAuxiliaryMetadata(builder);
                 addTitleOrIdentifier(builder);
             }
             metadata = builder.buildAndFreeze();
diff --git 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/xml/Store.java
 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/xml/Store.java
index 6e15afeb29..a432ff8673 100644
--- 
a/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/xml/Store.java
+++ 
b/endorsed/src/org.apache.sis.storage/main/org/apache/sis/storage/xml/Store.java
@@ -207,6 +207,7 @@ final class Store extends URIDataStore implements Filter {
                 final MetadataBuilder builder = new MetadataBuilder();
                 builder.addReferenceSystem((ReferenceSystem) object);
                 builder.addTitle(getDisplayName());
+                mergeAuxiliaryMetadata(builder);
                 metadata = builder.buildAndFreeze();
             }
         }
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/setup/OptionKey.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/setup/OptionKey.java
index 294a417bd2..0fc616d6f8 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/setup/OptionKey.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/setup/OptionKey.java
@@ -27,6 +27,7 @@ import java.nio.charset.Charset;
 import java.nio.file.OpenOption;
 import java.nio.file.StandardOpenOption;
 import static java.util.logging.Logger.getLogger;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.util.ArgumentChecks;
 import org.apache.sis.util.logging.Logging;
 import org.apache.sis.system.Modules;
@@ -62,7 +63,7 @@ import org.apache.sis.system.Modules;
  *     }
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.5
  *
  * @param <T>  the type of option values.
  *
@@ -181,6 +182,17 @@ public class OptionKey<T> implements Serializable {
      */
     public static final OptionKey<ByteBuffer> BYTE_BUFFER = new 
OptionKey<>("BYTE_BUFFER", ByteBuffer.class);
 
+    /**
+     * The coordinate reference system (CRS) of data to use if not explicitly 
defined.
+     * This option can be used when the file to read does not describe itself 
the data CRS.
+     * For example, this option can be used when reading ASCII Grid without 
CRS information,
+     * but is ignored if the ASCII Grid file is accompanied by a {@code *.prj} 
file giving the CRS.
+     *
+     * @since 1.5
+     */
+    public static final OptionKey<CoordinateReferenceSystem> DEFAULT_CRS =
+            new OptionKey<>("DEFAULT_CRS", CoordinateReferenceSystem.class);
+
     /**
      * The library to use for creating geometric objects at reading time.
      * Some libraries are the Java Topology Suite (JTS), ESRI geometry API and 
Java2D.
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/setup/package-info.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/setup/package-info.java
index 01f85a6d86..37c25bb411 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/setup/package-info.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/setup/package-info.java
@@ -23,7 +23,7 @@
  * is created.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.4
+ * @version 1.5
  * @since   0.3
  */
 package org.apache.sis.setup;
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/ArraysExt.java 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/ArraysExt.java
index f6af6788a1..328878e664 100644
--- a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/ArraysExt.java
+++ b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/ArraysExt.java
@@ -1237,15 +1237,60 @@ public final class ArraysExt extends Static {
         return copy;
     }
 
+    /**
+     * Removes all null elements in the given array. For each null element 
found in the array at index <var>i</var>,
+     * all elements at indices <var>i</var>+1, <var>i</var>+2, <var>i</var>+3, 
<i>etc.</i> are moved to indices
+     * <var>i</var>, <var>i</var>+1, <var>i</var>+2, <i>etc.</i>
+     * This method returns the new array length, which is {@code array.length} 
minus the number of null elements.
+     * The array content at indices equal or greater than the new length is 
undetermined.
+     *
+     * <p>Callers can obtain an array of appropriate length using the 
following idiom.
+     * Note that this idiom will create a new array only if necessary:</p>
+     *
+     * {@snippet lang="java" :
+     *     T[] array = ...;
+     *     array = resize(array, removeNulls(array));
+     *     }
+     *
+     * @param  array  array from which to remove null elements, or {@code 
null}.
+     * @return the number of remaining elements in the given array, or 0 if 
the given {@code array} was null.
+     *
+     * @since 1.5
+     */
+    public static int removeNulls(final Object[] array) {
+        if (array == null) {
+            return 0;
+        }
+        int i;
+        for (i=0; ; i++) {
+            if (i >= array.length) return i;            // Return if all 
values are non-null.
+            if (array[i] == null) break;                // Stop without 
incrementing `i`.
+        }
+        Object value;
+        int count = i;
+        do if (++i >= array.length) return count;       // Common case where 
all remaining values are null.
+        while ((value = array[i]) == null);
+
+        // Start copying values only on the portion of the array where it is 
needed.
+        array[count++] = value;
+        while (++i < array.length) {
+            value = array[i];
+            if (value != null) {
+                array[count++] = value;
+            }
+        }
+        return count;
+    }
+
     /**
      * Removes the duplicated elements in the given array. This method should 
be invoked only for small arrays,
      * typically less than 10 distinct elements. For larger arrays, use {@link 
java.util.LinkedHashSet} instead.
      *
-     * <p>This method compares all pair of elements using the {@link 
Objects#equals(Object, Object)}
-     * method - so null elements are allowed. If duplicated values are found, 
then only the first
-     * occurrence is retained; the second occurrence is removed in-place. 
After all elements have
-     * been compared, this method returns the number of remaining elements in 
the array. The free
-     * space at the end of the array is padded with {@code null} values.</p>
+     * <p>This method compares all pairs of elements using the {@link 
Objects#equals(Object, Object)} method -
+     * so null elements are allowed. If duplicated values are found,
+     * then only the first occurrence is retained and the second occurrence is 
removed in-place.
+     * After all elements have been compared, this method returns the number 
of remaining elements in the array.
+     * The free space at the end of the array is padded with {@code null} 
values.</p>
      *
      * <p>Callers can obtain an array of appropriate length using the 
following idiom.
      * Note that this idiom will create a new array only if necessary:</p>
@@ -1255,11 +1300,6 @@ public final class ArraysExt extends Static {
      *     array = resize(array, removeDuplicated(array));
      *     }
      *
-     * <div class="note"><b>API note:</b>
-     * This method return type is not an array in order to make obvious that 
the given array will be modified in-place.
-     * This behavior is different than the behavior of many other methods in 
this class, which do not modify the given
-     * source array.</div>
-     *
      * @param  array array from which to remove duplicated elements, or {@code 
null}.
      * @return the number of remaining elements in the given array, or 0 if 
the given {@code array} was null.
      */
@@ -1267,7 +1307,28 @@ public final class ArraysExt extends Static {
         if (array == null) {
             return 0;
         }
-        int length = array.length;
+        return removeDuplicated(array, array.length);
+    }
+
+    /**
+     * Removes the duplicated elements in the first elements of the given 
array.
+     * This method performs the same work than {@link 
#removeDuplicated(Object[])},
+     * but taking in account only the first {@code length} elements. The 
latter argument
+     * is convenient for chaining this method after {@link 
#removeNulls(Object[])} as below:
+     *
+     * {@snippet lang="java" :
+     *     T[] array = ...;
+     *     array = resize(array, removeDuplicated(array, removeNulls(array)));
+     *     }
+     *
+     * @param  array   array from which to remove duplicated elements, or 
{@code null}.
+     * @param  length  number of elements to examine at the beginning of the 
array.
+     * @return the number of remaining elements in the given array, or 0 if 
the given {@code array} was null.
+     * @throws ArrayIndexOutOfBoundsException if {@code length} is negative or 
greater than {@code array.length}.
+     *
+     * @since 1.5
+     */
+    public static int removeDuplicated(final Object[] array, int length) {
         for (int i=length; --i>=0;) {
             final Object value = array[i];
             for (int j=i; --j>=0;) {
diff --git 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/CollectionsExt.java
 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/CollectionsExt.java
index d24b297d2e..e43ae7e5d2 100644
--- 
a/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/CollectionsExt.java
+++ 
b/endorsed/src/org.apache.sis.util/main/org/apache/sis/util/internal/CollectionsExt.java
@@ -139,6 +139,22 @@ public final class CollectionsExt extends Static {
         return null;
     }
 
+    /**
+     * Returns the last element of the given iterable if it is a list, or an 
arbitrary element otherwise.
+     *
+     * @todo Check for sequenced collection in JDK21.
+     *
+     * @param  <T>         the type of elements contained in the iterable.
+     * @param  collection  the iterable from which to get the last element, or 
{@code null}.
+     * @return the last element, or {@code null} if the given iterable is null 
or empty.
+     */
+    public static <T> T last(final Collection<T> collection) {
+        if (collection instanceof List<?> && !collection.isEmpty()) {
+            return ((List<T>) collection).get(collection.size() - 1);
+        }
+        return null;
+    }
+
     /**
      * If the given iterable contains exactly one non-null element, returns 
that element.
      * Otherwise returns {@code null}.
diff --git 
a/endorsed/src/org.apache.sis.util/test/org/apache/sis/util/ArraysExtTest.java 
b/endorsed/src/org.apache.sis.util/test/org/apache/sis/util/ArraysExtTest.java
index 8dd68574be..cc70fad354 100644
--- 
a/endorsed/src/org.apache.sis.util/test/org/apache/sis/util/ArraysExtTest.java
+++ 
b/endorsed/src/org.apache.sis.util/test/org/apache/sis/util/ArraysExtTest.java
@@ -18,7 +18,7 @@ package org.apache.sis.util;
 
 // Test dependencies
 import org.junit.Test;
-import static org.junit.Assert.*;
+import static org.junit.jupiter.api.Assertions.*;
 import org.apache.sis.test.TestCase;
 
 
@@ -75,6 +75,16 @@ public final class ArraysExtTest extends TestCase {
                 ArraysExt.resize(array, ArraysExt.removeDuplicated(array)));
     }
 
+    /**
+     * Tests {@link ArraysExt#removeNulls(Object[])}.
+     */
+    @Test
+    public void testRemoveNulls() {
+        final Integer[] array = new Integer[] {2, 8, null, 4, null, 3, 1, 
null, null};
+        assertArrayEquals(new Integer[] {2, 8, 4, 3, 1},
+                ArraysExt.resize(array, ArraysExt.removeNulls(array)));
+    }
+
     /**
      * Tests {@link ArraysExt#reverse(int[])}.
      * The test uses an array of even length, then an array of odd length.
@@ -96,11 +106,11 @@ public final class ArraysExtTest extends TestCase {
     @Test
     public void testRange() {
         int[] sequence = ArraysExt.range(-1, 3);
-        assertArrayEquals("range", new int[] {-1, 0, 1, 2}, sequence);
-        assertTrue ("isRange", ArraysExt.isRange(-1, sequence));
-        assertFalse("isRange", ArraysExt.isRange(-2, sequence));
-        assertTrue ("isRange", ArraysExt.isRange(1, new int[] {1, 2, 3, 4}));
-        assertFalse("isRange", ArraysExt.isRange(1, new int[] {1, 2,    4}));
+        assertArrayEquals(new int[] {-1, 0, 1, 2}, sequence);
+        assertTrue (ArraysExt.isRange(-1, sequence));
+        assertFalse(ArraysExt.isRange(-2, sequence));
+        assertTrue (ArraysExt.isRange(1, new int[] {1, 2, 3, 4}));
+        assertFalse(ArraysExt.isRange(1, new int[] {1, 2,    4}));
     }
 
     /**
@@ -253,7 +263,7 @@ public final class ArraysExtTest extends TestCase {
     public void testSwapFloat() {
         final float[] array = new float[] {4, 8, 12, 15, 18};
         ArraysExt.swap(array, 1, 3);
-        assertArrayEquals(new float[] {4, 15, 12, 8, 18}, array, 0f);
+        assertArrayEquals(new float[] {4, 15, 12, 8, 18}, array);
     }
 
     /**
@@ -314,7 +324,7 @@ public final class ArraysExtTest extends TestCase {
         double[] array = {2, 0.5, 0.25, Double.NaN, Double.POSITIVE_INFINITY};
         float[] result = ArraysExt.copyAsFloatsIfLossless(array);
         assertNotNull(result);
-        assertArrayEquals(new float[] {2f, 0.5f, 0.25f, Float.NaN, 
Float.POSITIVE_INFINITY}, result, 0f);
+        assertArrayEquals(new float[] {2f, 0.5f, 0.25f, Float.NaN, 
Float.POSITIVE_INFINITY}, result);
         array[3] = 0.3333333333333;
         assertNull(ArraysExt.copyAsFloatsIfLossless(array));
     }

Reply via email to