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 a09dffd3c53a5290bab76d2c043808b0e722f738
Author: Martin Desruisseaux <martin.desruisse...@geomatys.com>
AuthorDate: Thu Aug 15 19:53:05 2024 +0200

    If a CRS is not found in the "SPATIAL_REF_SYS" table, optionally add a new 
record in that table.
    This feature need to be enabled by setting the `Database.allowAddCRS` flag 
to `true`,
    which should be done only when the caller intend to write in the database.
---
 .../apache/sis/metadata/sql/privy/SQLBuilder.java  |  13 +
 .../org/apache/sis/metadata/sql/TestDatabase.java  |   8 +-
 .../apache/sis/storage/sql/feature/Database.java   |  23 +-
 .../sis/storage/sql/feature/InfoStatements.java    | 349 ++++++++++++++++++---
 .../sis/storage/sql/feature/SpatialSchema.java     |  30 +-
 .../sis/storage/sql/postgis/ExtentEstimator.java   |   2 +-
 .../storage/sql/feature/InfoStatementsTest.java    | 152 +++++++++
 7 files changed, 518 insertions(+), 59 deletions(-)

diff --git 
a/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/metadata/sql/privy/SQLBuilder.java
 
b/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/metadata/sql/privy/SQLBuilder.java
index e3b3dac0a7..b341fedbfe 100644
--- 
a/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/metadata/sql/privy/SQLBuilder.java
+++ 
b/endorsed/src/org.apache.sis.metadata/main/org/apache/sis/metadata/sql/privy/SQLBuilder.java
@@ -306,6 +306,19 @@ public class SQLBuilder extends Syntax {
         return this;
     }
 
+    /**
+     * Removes the {@code WHERE} clause and everything after it.
+     *
+     * @return this builder, for method call chaining.
+     */
+    public final SQLBuilder removeWhereClause() {
+        final int index = buffer.indexOf(" WHERE ");
+        if (index >= 0) {
+            buffer.setLength(index);
+        }
+        return this;
+    }
+
     /**
      * Returns a SQL statement for adding a column in a table.
      * The returned statement is of the form:
diff --git 
a/endorsed/src/org.apache.sis.metadata/test/org/apache/sis/metadata/sql/TestDatabase.java
 
b/endorsed/src/org.apache.sis.metadata/test/org/apache/sis/metadata/sql/TestDatabase.java
index 6568d54fee..0da93bf15a 100644
--- 
a/endorsed/src/org.apache.sis.metadata/test/org/apache/sis/metadata/sql/TestDatabase.java
+++ 
b/endorsed/src/org.apache.sis.metadata/test/org/apache/sis/metadata/sql/TestDatabase.java
@@ -139,13 +139,13 @@ public class TestDatabase implements AutoCloseable {
         if (TEST_DATABASE != null) {
             return new TestDatabase(TEST_DATABASE, Dialect.ANSI);
         }
-        final EmbeddedDataSource ds = new EmbeddedDataSource();
+        final var ds = new EmbeddedDataSource();
         ds.setDatabaseName("memory:" + name);
         ds.setDataSourceName("Apache SIS test database");
         ds.setCreateDatabase("create");
         return new TestDatabase(ds, Dialect.DERBY) {
             @Override public void close() throws SQLException {
-                final EmbeddedDataSource ds = (EmbeddedDataSource) source;
+                final var ds = (EmbeddedDataSource) source;
                 ds.setCreateDatabase("no");
                 ds.setConnectionAttributes("drop=true");
                 try {
@@ -183,7 +183,7 @@ public class TestDatabase implements AutoCloseable {
             pool.setURL(url);
             ds = pool;
         } else {
-            final org.hsqldb.jdbc.JDBCDataSource simple = new 
org.hsqldb.jdbc.JDBCDataSource();
+            final var simple = new org.hsqldb.jdbc.JDBCDataSource();
             simple.setURL(url);
             ds = simple;
             pool = null;
@@ -216,7 +216,7 @@ public class TestDatabase implements AutoCloseable {
          * The DB_CLOSE_DELAY=-1 parameter keeps the database alive until 
SHUTDOWN is invoked.
          */
         final String url = "jdbc:h2:mem:" + name + ";DB_CLOSE_DELAY=-1";
-        final org.h2.jdbcx.JdbcDataSource ds = new 
org.h2.jdbcx.JdbcDataSource();
+        final var ds = new org.h2.jdbcx.JdbcDataSource();
         ds.setURL(url);
         return new TestDatabase(ds, Dialect.ANSI) {
             @Override public void close() throws SQLException {
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Database.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Database.java
index 75c9a39306..cf485bceb2 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Database.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/Database.java
@@ -135,8 +135,7 @@ public class Database<G> extends Syntax  {
     /**
      * All tables known to this {@code Database} in declaration order.
      * This array contains only the tables specified at initialization time, 
not the dependencies.
-     * This field is initialized by {@link #analyze(SQLStore, Connection, 
ResourceDefinition...)}
-     * and shall not be modified after that point.
+     * This field is initialized by {@link #analyze analyze(…)} and shall not 
be modified after that point.
      */
     private Table[] tables;
 
@@ -159,6 +158,12 @@ public class Database<G> extends Syntax  {
      */
     final EnumSet<CRSEncoding> crsEncodings;
 
+    /**
+     * Whether to allow the addition of new <abbr>CRS</abbr> definitions in 
the {@code SPATIAL_REF_SYS} table.
+     * The default value is {@code false}, which is preferable for read-only 
databases.
+     */
+    public volatile boolean allowAddCRS;
+
     /**
      * {@code true} if this database contains at least one geometry column.
      * This field is initialized by {@link #analyze analyze(…)} and shall not 
be modified after that point.
@@ -552,14 +557,14 @@ public class Database<G> extends Syntax  {
     }
 
     /**
-     * Appends a call to a function defined in the spatial schema.
-     * The function name will be prefixed by catalog and schema name if 
applicable.
-     * The function will not be quoted.
+     * Appends a call to a function or table defined in the spatial schema.
+     * The name will be prefixed by catalog and schema name if applicable.
+     * The name will not be quoted.
      *
-     * @param  sql       the SQL builder where to add the spatial function 
name.
-     * @param  function  the function to append.
+     * @param  sql   the SQL builder where to add the spatial function or 
table name.
+     * @param  name  the function or table to append.
      */
-    public final void appendFunctionCall(final SQLBuilder sql, final String 
function) {
+    public final void appendSpatialSchema(final SQLBuilder sql, final String 
name) {
         final String schema = schemaOfSpatialTables;
         if (schema != null && !schema.isEmpty()) {
             final String catalog = catalogOfSpatialTables;
@@ -568,7 +573,7 @@ public class Database<G> extends Syntax  {
             }
             sql.appendIdentifier(schema).append('.');
         }
-        sql.append(function);
+        sql.append(name);
     }
 
     /**
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/InfoStatements.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/InfoStatements.java
index ac6e66e91d..917ed56fc9 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/InfoStatements.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/InfoStatements.java
@@ -19,7 +19,9 @@ package org.apache.sis.storage.sql.feature;
 import java.util.Map;
 import java.util.Set;
 import java.util.HashSet;
+import java.util.LinkedHashMap;
 import java.util.AbstractMap.SimpleImmutableEntry;
+import java.util.ArrayList;
 import java.util.Iterator;
 import java.util.Locale;
 import java.util.logging.Level;
@@ -29,6 +31,7 @@ import java.sql.Array;
 import java.sql.Connection;
 import java.sql.PreparedStatement;
 import java.sql.ResultSet;
+import java.sql.Statement;
 import java.sql.SQLException;
 import org.opengis.referencing.IdentifiedObject;
 import org.opengis.referencing.NoSuchAuthorityCodeException;
@@ -74,6 +77,18 @@ import org.opengis.metadata.Identifier;
  * @see <a href="https://www.ogc.org/standards/sfs";>OGC Simple feature access 
— Part 2: SQL option</a>
  */
 public class InfoStatements implements Localized, AutoCloseable {
+    /**
+     * Upper limit of the range of low SRID codes that we may use for custom 
CRS.
+     * This is used only if the EPSG code cannot be used.
+     */
+    private static final int LOW_SRID_RANGE = 1000;
+
+    /**
+     * Upper limit of the high range of SRID codes that we can use for custom 
CRS.
+     * This is used only if the EPSG code cannot be used.
+     */
+    private static final int HIGH_SRID_RANGE = 40000;
+
     /**
      * The database that created this set of cached statements. This object 
includes the
      * cache of CRS created from SRID codes and the listeners where to send 
warnings.
@@ -105,9 +120,11 @@ public class InfoStatements implements Localized, 
AutoCloseable {
     private PreparedStatement sridFromCRS;
 
     /**
-     * The object to use for parsing Well-Known Text (WKT), created when first 
needed.
+     * The object to use for parsing or formatting Well-Known Text (WKT), 
created when first needed.
+     *
+     * @see #wktFormat()
      */
-    private WKTFormat wktReader;
+    private WKTFormat wktFormat;
 
     /**
      * Creates an initially empty {@code CachedStatements} which will use
@@ -146,11 +163,7 @@ public class InfoStatements implements Localized, 
AutoCloseable {
      * The table name will be prefixed by catalog and schema name if 
applicable.
      */
     private void appendFrom(final SQLBuilder sql, final String table) {
-        /*
-         * Despite its name, `appendFunctionCall(…)` can also be used for 
formatting
-         * table names provided that we want unquoted names (which is the case 
here).
-         */
-        database.appendFunctionCall(sql.append(" FROM "), table);
+        database.appendSpatialSchema(sql.append(" FROM "), table);
         sql.append(" WHERE ");
     }
 
@@ -299,8 +312,7 @@ public class InfoStatements implements Localized, 
AutoCloseable {
      */
     private PreparedStatement prepareSearchCRS(final boolean byAuthorityCode) 
throws SQLException {
         final SpatialSchema schema = database.getSpatialSchema().orElseThrow();
-        final SQLBuilder sql = new SQLBuilder(database);
-        sql.append(SQLBuilder.SELECT);
+        final SQLBuilder sql = new 
SQLBuilder(database).append(SQLBuilder.SELECT);
         if (byAuthorityCode) {
             sql.append(schema.crsIdentifierColumn);
         } else {
@@ -393,7 +405,7 @@ public class InfoStatements implements Localized, 
AutoCloseable {
                          * Following warnings may have occurred during WKT 
parsing and are considered minor.
                          * They will be reported only if there are no more 
important warnings to report.
                          */
-                        final Warnings w = wktReader.getWarnings();
+                        final Warnings w = wktFormat.getWarnings();
                         if (w != null) {
                             warning = new LogRecord(Level.WARNING, 
w.toString(getLocale()));
                         }
@@ -412,13 +424,23 @@ public class InfoStatements implements Localized, 
AutoCloseable {
             final SpatialSchema schema = 
database.getSpatialSchema().orElseThrow();
             throw invalidSRID(Resources.Keys.UnknownSRID_2, schema.crsTable, 
srid, null);
         }
+        log("fetchCRS", warning);
+        return crs;
+    }
+
+    /**
+     * Logs the given warning if it is non-null.
+     *
+     * @param  method   name of the method logging a warning.
+     * @param  warning  the warning to log, or {@code null} if none.
+     */
+    private void log(final String method, final LogRecord warning) {
         if (warning != null) {
             warning.setLoggerName(Modules.SQL);
             warning.setSourceClassName(getClass().getName());
-            warning.setSourceMethodName("fetchCRS");
+            warning.setSourceMethodName(method);
             database.listeners.warning(warning);
         }
-        return crs;
     }
 
     /**
@@ -435,8 +457,7 @@ public class InfoStatements implements Localized, 
AutoCloseable {
     private DataStoreContentException invalidSRID(final short message, final 
Object complement, final int srid,
             final NoSuchAuthorityCodeException suppressed)
     {
-        final DataStoreContentException e = new DataStoreContentException(
-                Resources.forLocale(getLocale()).getString(message, 
complement, srid));
+        final var e = new 
DataStoreContentException(Resources.forLocale(getLocale()).getString(message, 
complement, srid));
         if (suppressed != null) {
             e.addSuppressed(suppressed);
         }
@@ -445,8 +466,11 @@ public class InfoStatements implements Localized, 
AutoCloseable {
 
     /**
      * Finds a SRID code from the spatial reference systems table for the 
given CRS.
+     * If the database does not support concurrent transactions, then the 
caller is
+     * responsible for holding a lock. It may be a read lock or write lock 
depending
+     * on the {@link Database#allowAddCRS} value.
      *
-     * @param  crs  the CRS for which to find a SRID, or {@code null}.
+     * @param  crs     the CRS for which to find a SRID, or {@code null}.
      * @return SRID for the given CRS, or 0 if the given CRS was null.
      * @throws Exception if an SQL error, parsing error or other error 
occurred.
      */
@@ -460,21 +484,26 @@ public class InfoStatements implements Localized, 
AutoCloseable {
                 return srid;
             }
         }
-        final Set<SimpleImmutableEntry<String,String>> done = new HashSet<>();
-        Iterator<IdentifiedObject> alternatives = null;
-        IdentifiedObject candidate = crs;
+        /*
+         * Search in the database. In the `done` map, keys are 
"authority:code" pairs that we
+         * already tried and values tell whether we can use that pair if we 
need to add new CRS.
+         */
         Exception error = null;
-        for (;;) {
+        boolean tryWithGivenCRS = true;
+        final var sridFounInUse = new HashSet<Integer>();
+        final var done = new 
LinkedHashMap<SimpleImmutableEntry<String,Object>, Boolean>();
+        for (Iterator<IdentifiedObject> it = 
Set.<IdentifiedObject>of(crs).iterator(); it.hasNext();) {
+            final IdentifiedObject candidate = it.next();
             /*
              * First, iterate over the identifiers declared in the CRS object.
              * If we cannot find an identifier that we can map to a SRID, then 
this loop may be
-             * executed more times with CRS from EPSG database that are equal, 
ignore axis order.
+             * executed more times with CRS from EPSG database that are equal, 
ignoring axis order.
              */
             for (final Identifier id : candidate.getIdentifiers()) {
                 final String authority = id.getCodeSpace();
                 if (authority == null) continue;
                 final String code = id.getCode();
-                if (!done.add(new SimpleImmutableEntry<>(authority, code))) {
+                if (done.putIfAbsent(new SimpleImmutableEntry<>(authority, 
code), Boolean.FALSE) != null) {
                     continue;                           // Skip 
"authority:code" that we already tried.
                 }
                 final int codeValue;
@@ -485,6 +514,10 @@ public class InfoStatements implements Localized, 
AutoCloseable {
                     else error.addSuppressed(e);
                     continue;                           // Ignore codes that 
are not integers.
                 }
+                final var key = new 
SimpleImmutableEntry<String,Object>(authority, codeValue);
+                if (done.putIfAbsent(key, codeValue > 0) != null) {
+                    continue;
+                }
                 /*
                  * Found an "authority:code" pair that we did not tested 
before.
                  * Get the WKT and verifies if the CRS is approximately equal.
@@ -496,10 +529,10 @@ public class InfoStatements implements Localized, 
AutoCloseable {
                 sridFromCRS.setInt(2, codeValue);
                 try (ResultSet result = sridFromCRS.executeQuery()) {
                     while (result.next()) {
-                        try {
+                        final int srid = result.getInt(1);
+                        if (sridFounInUse.add(srid)) try {
                             final Object parsed = parseDefinition(result, 2);
                             if (Utilities.equalsApproximately(parsed, crs)) {
-                                final int srid = result.getInt(1);
                                 synchronized (database.cacheOfSRID) {
                                     database.cacheOfSRID.put(crs, srid);
                                 }
@@ -509,6 +542,7 @@ public class InfoStatements implements Localized, 
AutoCloseable {
                             if (error == null) error = e;
                             else error.addSuppressed(e);
                         }
+                        done.put(key, Boolean.FALSE);       // Declare this 
"authority:code" pair as not available.
                     }
                 }
             }
@@ -517,16 +551,247 @@ public class InfoStatements implements Localized, 
AutoCloseable {
              * It may be because the CRS has no identifier at all. Search for
              * possible identifiers in the EPSG database, then try them.
              */
-            if (alternatives == null) {
+            if (tryWithGivenCRS) {
+                tryWithGivenCRS = false;
                 final IdentifiedObjectFinder finder = 
IdentifiedObjects.newFinder(Constants.EPSG);
                 finder.setIgnoringAxes(true);
-                alternatives = finder.find(crs).iterator();
+                it = finder.find(crs).iterator();
+            }
+        }
+        /*
+         * At this point, we found no CRS definition in the current 
`SPATIAL_REF_SYS` table.
+         * If the caller allowed the creation of new rows in that table, 
creates it now.
+         * It is caller's responsibility to hold a write lock if needed.
+         */
+        if (database.allowAddCRS) {
+            String fallback = null;
+            int fallbackCode = 0;
+            for (final Map.Entry<SimpleImmutableEntry<String,Object>, Boolean> 
entry : done.entrySet()) {
+                if (entry.getValue()) {
+                    final SimpleImmutableEntry<String,Object> identifier = 
entry.getKey();
+                    final int code = (Integer) identifier.getValue();  // Safe 
cast when `entry.getValue()` is true.
+                    final String authority = identifier.getKey();
+                    if (Constants.EPSG.equalsIgnoreCase(authority)) {
+                        return addCRS(authority, code, crs, sridFounInUse);
+                    }
+                    if (fallback == null) {
+                        fallback = authority;
+                        fallbackCode = code;
+                    }
+                }
+            }
+            if (fallback != null) {
+                return addCRS(fallback, fallbackCode, crs, sridFounInUse);
+            }
+            // In the current version, we don't encode a CRS without an 
"authority:code" pair.
+        }
+        final Locale locale = getLocale();
+        throw new 
DataStoreReferencingException(Resources.forLocale(locale).getString(
+                Resources.Keys.CanNotFindSRID_1, 
IdentifiedObjects.getDisplayName(crs, locale)), error);
+    }
+
+    /**
+     * Adds a new entry in the {@code SPATIAL_REF_SYS} table for the given 
<abbr>CRS</abbr>.
+     * The {@code authority} and {@code code} arguments are the values to 
store in the {@code "AUTH_NAME"} and
+     * {@code "AUTH_SRID"} columns respectively. A common usage is to prefer 
EPSG codes, but this is not mandatory.
+     *
+     * @param  authority      authority providing the CRS definition.
+     * @param  code           authority code.
+     * @param  crs            the coordinate reference system to encode.
+     * @param  sridFounInUse  SRIDs which have been found in use, for avoiding 
SRID that would be certain to fail.
+     *                        This is only a hint. A SRID not in this set is 
not a guarantee that it is available.
+     * @return SRID of the CRS added by this method.
+     * @throws DataStoreReferencingException if the given CRS cannot be 
encoded in at least one supported format.
+     * @throws SQLException if an error occurred while executing the SQL 
statement.
+     */
+    private int addCRS(final String authority, final int code, final 
CoordinateReferenceSystem crs,
+            final Set<Integer> sridFounInUse) throws Exception
+    {
+        final SpatialSchema schema = database.getSpatialSchema().orElseThrow();
+        final var sql = new SQLBuilder(database).append(SQLBuilder.INSERT);
+        database.appendSpatialSchema(sql, schema.crsTable);
+        sql.append(" (").append(schema.crsIdentifierColumn)
+           .append(", ").append(schema.crsAuthorityNameColumn)
+           .append(", ").append(schema.crsAuthorityCodeColumn);
+        /*
+         * Append a variable number of columns for the CRS definitions in 
various formats.
+         * We add columns only for the formats that we can use. At least one 
of them must
+         * be filled, otherwise we cannot add the row.
+         */
+        @SuppressWarnings("LocalVariableHidesMemberVariable")
+        final var wktFormat    = wktFormat();
+        final var crsEncodings = database.crsEncodings;
+        final var warnings     = new Warnings[crsEncodings.size()];
+        final var definitions  = new String[warnings.length + 2];       // +2 
columns for name and description.
+        int numDefinitions = 0, numWarnings = 0;
+        for (final CRSEncoding encoding : crsEncodings) {
+            final String def;
+            switch (encoding) {
+                default: continue;          // Skip unknown formats (none at 
this time).
+                case WKT1: def = wktFormat.format(crs); break;
+                case WKT2: {
+                    try {
+                        wktFormat.setConvention(Convention.WKT2);
+                        def = wktFormat.format(crs);
+                    } finally {
+                        wktFormat.setConvention(Convention.WKT1_COMMON_UNITS);
+                    }
+                    break;
+                }
+            }
+            Warnings warning = wktFormat.getWarnings();
+            if (warning != null) {
+                warnings[numWarnings++] = warning;
+            } else {
+                definitions[numDefinitions++] = def;
+                sql.append(", 
").append(schema.crsDefinitionColumn.get(encoding));
+            }
+        }
+        /*
+         * If we have not been able to format the CRS in any encoding, throw 
an exception with the first warning.
+         * We take the first one because the `CRSEncodng` enumeration is 
ordered with most complete formats first,
+         * so if we fail with the first encoding the next ones should be worst.
+         */
+        if (numDefinitions == 0) {
+            throw new 
DataStoreReferencingException(warnings[0].toString(getLocale()));
+        }
+        for (int i=0; i<numWarnings; i++) {
+            log("addCRS", new LogRecord(Level.WARNING, 
warnings[i].toString(getLocale())));
+        }
+        /*
+         * Optional columns for CRS name and description.
+         * The following loop is executed exactly twice.
+         */
+        boolean description = false;
+        do {
+            final String column = description ? schema.crsDescriptionColumn : 
schema.crsNameColumn;
+            if (column != null) {
+                String name = description ? 
IdentifiedObjects.getDisplayName(crs, getLocale())
+                                          : IdentifiedObjects.getName(crs, 
null);
+                if (name != null) {
+                    definitions[numDefinitions++] = name;
+                    sql.append(", ").append(column);
+                }
+            }
+        } while ((description = !description) == true);
+        /*
+         * Complete the SQL statement with the parameters that we will need to 
provide.
+         * In addition to the definition, there is 3 columns for (SRID, 
AUTHORITY, CODE)
+         * columns, and one more column if we also store the CRS name.
+         */
+        String separator = ") VALUES (";
+        for (int i = numDefinitions + 3; --i >= 0;) {
+            sql.append(separator).append('?');
+            separator = ", ";
+        }
+        int srid = code;
+        try (PreparedStatement stmt = 
connection.prepareStatement(sql.append(')').toString())) {
+            stmt.setString(2, authority);
+            stmt.setInt(3, code);
+            for (int i=0; i<numDefinitions; i++) {
+                stmt.setString(4+i, definitions[i]);
+            }
+            /*
+             * Execute the statement first by trying to use a SRID of the same 
value as the authority code.
+             * We do that because a common common practice is to use EPSG code 
as SRID values. If the SRID
+             * is not available (i.e., if we get an integrity violation), we 
will try another SRID value.
+             */
+            final var failures = new ArrayList<Exception>();
+            if (!sridFounInUse.contains(srid)) try {
+                stmt.setInt(1, srid);
+                stmt.executeUpdate();
+                return srid;
+            } catch (SQLException e) {
+                filterConstraintViolation(e);
+                /*
+                 * SQL state category 23: integrity constraint violation. 
Maybe the new CRS has been added concurrently.
+                 * Or maybe the CRS for that SRID would have been suitable but 
`findSRID(…)` didn't saw it, for example
+                 * because the authority name has different spelling or 
different lower/upper case.
+                 */
+                try {
+                    final CoordinateReferenceSystem candidate = fetchCRS(srid);
+                    if (Utilities.equalsIgnoreMetadata(crs, candidate)) {
+                        return srid;
+                    }
+                } catch (Exception f) {
+                    failures.add(f);
+                }
+                failures.add(e);
+            }
+            /*
+             * Search for an available SRID, then try again. The loop should 
be executed only once,
+             * unless the database content changed concurrently. In the latter 
case we retry until
+             * we got a free SRID.
+             */
+            try {
+                srid = 0;
+                while (srid < (srid = findFreeSRID(schema, 
sql.clear().append(SQLBuilder.SELECT)))) {
+                    try {
+                        stmt.setInt(1, srid);
+                        stmt.executeUpdate();
+                        return srid;
+                    } catch (SQLException e) {
+                        filterConstraintViolation(e);
+                        failures.add(e);
+                    }
+                }
+            } catch (Exception e) {
+                failures.add(e);
+            }
+            /*
+             * If an unexpected error occurred, rethrow the last error and add 
all previous errors
+             * as suppressed exceptions, in reverse order. We chose the last 
exception because, in
+             * case of integrity violation, it is the record which was 
supposed to be okay.
+             */
+            int i = failures.size();
+            final Exception e = failures.get(--i);
+            while (--i >= 0) e.addSuppressed(failures.get(i));
+            throw e;
+        }
+    }
+
+    /**
+     * Rethrows the given exception if the SQL state is not category 23: 
integrity constraint violation.
+     * If the exception is a integrity constraint violation, do nothing.
+     *
+     * @param  e  the exception to filter.
+     * @throws SQLException if the given exception is not of category 23.
+     */
+    private static void filterConstraintViolation(final SQLException e) throws 
SQLException {
+        final String state = e.getSQLState();
+        if (state == null || !state.equals("23505")) throw e;
+    }
+
+    /**
+     * Searches a free SRID for a new CRS definition.
+     *
+     * @param  schema  value of {@link Database#getSpatialSchema()}.
+     * @param  sql     a preexisting builder initialized with the {@code 
"SELECT "} string.
+     * @return an available SRID guaranteed to be greater than zero.
+     * @throws SQLException if an error occurred while searching for a free 
SRID.
+     */
+    private int findFreeSRID(final SpatialSchema schema, final SQLBuilder sql) 
throws SQLException {
+        
appendFrom(sql.append("MAX(").append(schema.crsIdentifierColumn).append(')'), 
schema.crsTable);
+        
sql.append(schema.crsIdentifierColumn).append('<').append(LOW_SRID_RANGE);
+        try (Statement stmt = connection.createStatement()) {
+            for (boolean high = false;;) {                              // 
Loop will be executed 1 or 2 times.
+                try (ResultSet result = stmt.executeQuery(sql.toString())) {
+                    if (result.next()) {
+                        final int srid = result.getInt(1) + 1;          // 
Next value after the highest one.
+                        if (high) {
+                            return Math.max(srid, HIGH_SRID_RANGE);
+                        } else if (srid < LOW_SRID_RANGE) {
+                            return Math.max(srid, 1);
+                        }
+                    }
+                }
+                if (high) {
+                    return 1;
+                }
+                high = true;
+                sql.removeWhereClause();
             }
-            if (!alternatives.hasNext()) break;
-            candidate = alternatives.next();
         }
-        throw new DataStoreReferencingException(Resources.format(
-                Resources.Keys.CanNotFindSRID_1, 
IdentifiedObjects.getDisplayName(crs, null)), error);
     }
 
     /**
@@ -539,24 +804,32 @@ public class InfoStatements implements Localized, 
AutoCloseable {
      */
     private Object parseDefinition(final ResultSet result, int column) throws 
SQLException, ParseException {
         for (CRSEncoding encoding : database.crsEncodings) {
-            final String wkt = result.getString(column++);
-            if (wkt == null || wkt.isBlank()) {
+            final String def = result.getString(column++);
+            // Note: Geopackage stores "undefined" instead of no value.
+            if (def == null || def.isBlank() || 
def.equalsIgnoreCase("undefined")) {
                 continue;
             }
             switch (encoding) {
-                default: {
-                    if (wktReader == null) {
-                        wktReader = new WKTFormat();
-                        wktReader.setConvention(Convention.WKT1_COMMON_UNITS);
-                    }
-                    return wktReader.parseObject(wkt);
-                }
+                default: return wktFormat().parseObject(def);
                 // JSON encoding may be added in a future version.
             }
         }
         return null;
     }
 
+    /**
+     * Returns the object to use for parsing or formatting Well-Known Text of 
<abbr>CRS</abbr>.
+     * The parser/formatter is created when first needed.
+     */
+    private WKTFormat wktFormat() {
+        if (wktFormat == null) {
+            wktFormat = new WKTFormat();
+            wktFormat.setIndentation(WKTFormat.SINGLE_LINE);
+            wktFormat.setConvention(Convention.WKT1_COMMON_UNITS);
+        }
+        return wktFormat;
+    }
+
     /**
      * Closes all prepared statements. This method does <strong>not</strong> 
close the connection.
      *
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/SpatialSchema.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/SpatialSchema.java
index e405545841..cd2b2c3204 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/SpatialSchema.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/feature/SpatialSchema.java
@@ -40,10 +40,10 @@ public enum SpatialSchema {
      * except for table names, for the case (Geopackage uses lower case) and 
for the addition of a
      * {@code geometry_type_name} column.
      */
-    GEOPACKAGE("gpkg_spatial_ref_sys", "srs_id", "organization", 
"organization_coordsys_id",
+    GEOPACKAGE("gpkg_spatial_ref_sys", "srs_name", "srs_id", "organization", 
"organization_coordsys_id",
                Map.of(CRSEncoding.WKT1, "definition",
                       CRSEncoding.WKT2, "definition_12_063"),
-
+               "description",
                "gpkg_geometry_columns", "table_catalog", "table_schema", 
"table_name",
                "column_name", "geometry_type_name", 
GeometryTypeEncoding.TEXTUAL),
 
@@ -64,8 +64,8 @@ public enum SpatialSchema {
      * In Geopackage, this table is named {@code "gpkg_spatial_ref_sys"} but 
otherwise has identical content
      * except for the case (Geopackage uses lower case).
      */
-    SQL_MM("ST_SPATIAL_REFERENCE_SYSTEMS", "SRS_ID", "ORGANIZATION", 
"ORGANIZATION_COORDSYS_ID",
-           Map.of(CRSEncoding.WKT1, "DEFINITION"),
+    SQL_MM("ST_SPATIAL_REFERENCE_SYSTEMS", "SRS_NAME", "SRS_ID", 
"ORGANIZATION", "ORGANIZATION_COORDSYS_ID",
+           Map.of(CRSEncoding.WKT1, "DEFINITION"), "DESCRIPTION",
            "ST_GEOMETRY_COLUMNS", "TABLE_CATALOG", "TABLE_SCHEMA", 
"TABLE_NAME", "COLUMN_NAME", null, null),
 
     /**
@@ -82,7 +82,7 @@ public enum SpatialSchema {
      *   SRTEXT CHARACTER VARYING(2048))
      * }
      */
-    SIMPLE_FEATURE("SPATIAL_REF_SYS", "SRID", "AUTH_NAME", "AUTH_SRID", 
Map.of(CRSEncoding.WKT1, "SRTEXT"),
+    SIMPLE_FEATURE("SPATIAL_REF_SYS", null, "SRID", "AUTH_NAME", "AUTH_SRID", 
Map.of(CRSEncoding.WKT1, "SRTEXT"), null,
                    "GEOMETRY_COLUMNS", "F_TABLE_CATALOG", "F_TABLE_SCHEMA", 
"F_TABLE_NAME", "F_GEOMETRY_COLUMN",
                    "GEOMETRY_TYPE", GeometryTypeEncoding.NUMERIC);
 
@@ -92,6 +92,12 @@ public enum SpatialSchema {
      */
     final String crsTable;
 
+    /**
+     * Name of the column for CRS name, or {@code null} if none.
+     * Example: {@code "SRS_NAME"}.
+     */
+    final String crsNameColumn;
+
     /**
      * Name of the column for CRS identifiers.
      * Example: {@code "SRID"}, {@code "SRS_ID"}.
@@ -118,6 +124,11 @@ public enum SpatialSchema {
      */
     final Map<CRSEncoding, String> crsDefinitionColumn;
 
+    /**
+     * Name of the column for the CRS description, or {@code null} if none.
+     */
+    final String crsDescriptionColumn;
+
     /**
      * Name of the table enumerating the geometry columns.
      */
@@ -162,10 +173,12 @@ public enum SpatialSchema {
      * Creates a new enumeration value.
      *
      * @param crsTable                name of the table for Spatial Reference 
System definitions.
+     * @param crsNameColumn           name of the column for CRS names, or 
{@code null} if none.
      * @param crsIdentifierColumn     name of the column for CRS identifiers.
      * @param crsAuthorityNameColumn  name of the column for CRS authority 
names.
      * @param crsAuthorityCodeColumn  name of the column for CRS authority 
codes.
      * @param crsDefinitionColumn     name of the column for CRS definitions 
in <abbr>WKT</abbr> format.
+     * @param crsDescriptionColumn    name of the column for the CRS 
description, or {@code null} if none.
      * @param geometryColumns         name of the table enumerating the 
geometry columns.
      * @param geomCatalogColumn       name of the column where the catalog of 
each geometry column is stored.
      * @param geomSchemaColumn        name of the column where the schema of 
each geometry column is stored.
@@ -174,17 +187,20 @@ public enum SpatialSchema {
      * @param geomTypeColumn          name of the column where the type of 
each geometry column is stored, or null if none.
      * @param typeEncoding            how geometry types are encoded in the 
{@link #geomTypeColumn}.
      */
-    private SpatialSchema(String crsTable, String crsIdentifierColumn, String 
crsAuthorityNameColumn,
-                          String crsAuthorityCodeColumn, 
Map<CRSEncoding,String> crsDefinitionColumn,
+    private SpatialSchema(String crsTable, String crsNameColumn, String 
crsIdentifierColumn,
+                          String crsAuthorityNameColumn, String 
crsAuthorityCodeColumn,
+                          Map<CRSEncoding,String> crsDefinitionColumn, String 
crsDescriptionColumn,
                           String geometryColumns, String geomCatalogColumn, 
String geomSchemaColumn,
                           String geomTableColumn, String geomColNameColumn, 
String geomTypeColumn,
                           GeometryTypeEncoding typeEncoding)
     {
         this.crsTable               = crsTable;
+        this.crsNameColumn          = crsNameColumn;
         this.crsIdentifierColumn    = crsIdentifierColumn;
         this.crsAuthorityNameColumn = crsAuthorityNameColumn;
         this.crsAuthorityCodeColumn = crsAuthorityCodeColumn;
         this.crsDefinitionColumn    = crsDefinitionColumn;
+        this.crsDescriptionColumn   = crsDescriptionColumn;
         this.geometryColumns        = geometryColumns;
         this.geomCatalogColumn      = geomCatalogColumn;
         this.geomSchemaColumn       = geomSchemaColumn;
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/ExtentEstimator.java
 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/ExtentEstimator.java
index 97367f75a6..d9680f2620 100644
--- 
a/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/ExtentEstimator.java
+++ 
b/endorsed/src/org.apache.sis.storage.sql/main/org/apache/sis/storage/sql/postgis/ExtentEstimator.java
@@ -120,7 +120,7 @@ final class ExtentEstimator {
     private void query(final Statement statement) throws SQLException {
         for (final Column column : columns) {
             if (column.getGeometryType().isPresent()) {
-                database.appendFunctionCall(builder.append(SQLBuilder.SELECT), 
"ST_EstimatedExtent");
+                
database.appendSpatialSchema(builder.append(SQLBuilder.SELECT), 
"ST_EstimatedExtent");
                 builder.append('(');
                 if (table.schema != null) {
                     builder.appendValue(table.schema).append(", ");
diff --git 
a/endorsed/src/org.apache.sis.storage.sql/test/org/apache/sis/storage/sql/feature/InfoStatementsTest.java
 
b/endorsed/src/org.apache.sis.storage.sql/test/org/apache/sis/storage/sql/feature/InfoStatementsTest.java
new file mode 100644
index 0000000000..da2e2822fe
--- /dev/null
+++ 
b/endorsed/src/org.apache.sis.storage.sql/test/org/apache/sis/storage/sql/feature/InfoStatementsTest.java
@@ -0,0 +1,152 @@
+/*
+ * 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.storage.sql.feature;
+
+import java.util.Map;
+import java.util.List;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.lang.reflect.Field;
+import org.opengis.referencing.crs.CoordinateReferenceSystem;
+import org.apache.sis.referencing.crs.DefaultGeographicCRS;
+import org.apache.sis.metadata.sql.privy.Dialect;
+import org.apache.sis.geometry.wrapper.Geometries;
+import org.apache.sis.setup.GeometryLibrary;
+import org.apache.sis.storage.DataStoreReferencingException;
+import org.apache.sis.storage.event.StoreListeners;
+
+// Test dependencies
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.apache.sis.test.Assertions.assertMessageContains;
+import org.apache.sis.test.TestCase;
+import org.apache.sis.storage.DataStoreMock;
+import org.apache.sis.metadata.sql.TestDatabase;
+import org.apache.sis.referencing.ImmutableIdentifier;
+import org.apache.sis.referencing.cs.HardCodedCS;
+import org.apache.sis.referencing.crs.HardCodedCRS;
+import org.apache.sis.referencing.datum.HardCodedDatum;
+
+
+/**
+ * Tests {@link InfoStatements}.
+ *
+ * @author Martin Desruisseaux (Geomatys)
+ */
+@Execution(ExecutionMode.SAME_THREAD)
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+public final class InfoStatementsTest extends TestCase {
+    /**
+     * A in-memory database on Derby.
+     */
+    private final TestDatabase test;
+
+    /**
+     * SQL connection used for the tests.
+     */
+    private Connection connection;
+
+    /**
+     * The database to test.
+     */
+    private Database<?> database;
+
+    /**
+     * Creates a new test case.
+     *
+     * @throws SQLException if an error occurred while creating a temporary 
in-memory database.
+     */
+    public InfoStatementsTest() throws SQLException {
+        test = TestDatabase.create("InfoStatements");
+    }
+
+    /**
+     * Creates some tables needed by the tests, then creates a {@link 
Database} instance needed for the tests.
+     *
+     * @throws Exception if an error occurred while creating the tables.
+     */
+    @BeforeAll
+    public void initialize() throws Exception {
+        test.executeSQL(List.of("CREATE TABLE SPATIAL_REF_SYS (" +
+                "SRID INTEGER NOT NULL PRIMARY KEY, " +
+                "AUTH_NAME VARCHAR(100), " +
+                "AUTH_SRID INTEGER, " +
+                "SRTEXT VARCHAR(2000));"));
+
+        connection = test.source.getConnection();
+        database = new Database<>(test.source, connection.getMetaData(), 
Dialect.DERBY,
+                                  Geometries.factory(GeometryLibrary.JAVA2D),
+                                  new StoreListeners(null, new 
DataStoreMock("Unused")));
+        /*
+         * The `spatialSchema` is private, so we need to use reflection for 
setting its value.
+         * Normally that field would be set by `Database.analyze(…)`, bur we 
want to avoid that
+         * complexity for more isolated tests.
+         */
+        Field field = Database.class.getDeclaredField("spatialSchema");
+        field.setAccessible(true);
+        field.set(database, SpatialSchema.SIMPLE_FEATURE);
+        assertTrue(database.crsEncodings.add(CRSEncoding.WKT1));
+    }
+
+    /**
+     * Tests {@link InfoStatements#findSRID(CoordinateReferenceSystem)}.
+     *
+     * @throws Exception if a SQL, WKT or other error occurred.
+     */
+    @Test
+    public void testFindSRID() throws Exception {
+        try (InfoStatements info = new InfoStatements(database, connection)) {
+            database.allowAddCRS = false;
+            final CoordinateReferenceSystem crs = HardCodedCRS.WGS84;
+            var e = assertThrows(DataStoreReferencingException.class, () -> 
info.findSRID(crs));
+            assertMessageContains(e, crs.getName().getCode());
+
+            // Now do the actual insertion.
+            database.allowAddCRS = true;  assertEquals(4326, 
info.findSRID(crs));
+            database.allowAddCRS = false; assertEquals(4326, 
info.findSRID(crs));
+
+            // CRS with the same code (intentional clash with EPSG:4326).
+            final CoordinateReferenceSystem clash = new DefaultGeographicCRS(
+                    Map.of(CoordinateReferenceSystem.NAME_KEY, "Sphere",
+                           CoordinateReferenceSystem.IDENTIFIERS_KEY, new 
ImmutableIdentifier(null, "FOO", "4326")),
+                    HardCodedDatum.SPHERE, null, HardCodedCS.GEODETIC_2D);
+
+            database.allowAddCRS = true;  assertEquals(1, 
info.findSRID(clash));
+            database.allowAddCRS = false; assertEquals(1, 
info.findSRID(clash));
+        }
+    }
+
+    /**
+     * Closes the temporary in-memory database.
+     *
+     * @throws SQLException if an error occurred while closing the database.
+     */
+    @AfterAll
+    public void close() throws SQLException {
+        try (test) {
+            if (connection != null) {
+                connection.close();
+                connection = null;
+            }
+        }
+    }
+}


Reply via email to