sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] branch geoapi-4.0 updated: Complete the construction of Feature instances from a SQL database. SQLStore now supports associations in both ways ("import" and "export" keys).
Date Wed, 18 Jul 2018 15:45:04 GMT
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


The following commit(s) were added to refs/heads/geoapi-4.0 by this push:
     new 3a24d77  Complete the construction of Feature instances from a SQL database. SQLStore now supports associations in both ways ("import" and "export" keys).
3a24d77 is described below

commit 3a24d775dabac0024a9e1b6fb22c183ee47ba4c1
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Wed Jul 18 17:43:51 2018 +0200

    Complete the construction of Feature instances from a SQL database.
    SQLStore now supports associations in both ways ("import" and "export" keys).
---
 .../sis/internal/metadata/sql/SQLBuilder.java      |  18 ++
 .../apache/sis/internal/sql/feature/Analyzer.java  |  22 +-
 .../apache/sis/internal/sql/feature/Database.java  |   2 +-
 .../apache/sis/internal/sql/feature/Features.java  | 236 ++++++++++++++++-----
 .../apache/sis/internal/sql/feature/Relation.java  | 148 +++++++++----
 .../org/apache/sis/internal/sql/feature/Table.java | 144 +++++++++----
 .../org/apache/sis/storage/sql/SQLStoreTest.java   |  75 +++++--
 .../org/apache/sis/storage/sql/Features.sql        |   8 +-
 .../sis/storage/InternalDataStoreException.java    |  71 +++++++
 9 files changed, 562 insertions(+), 162 deletions(-)

diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/SQLBuilder.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/SQLBuilder.java
index adc0352..21b6c5d 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/SQLBuilder.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/SQLBuilder.java
@@ -179,6 +179,24 @@ public class SQLBuilder {
     }
 
     /**
+     * Appends an identifier for an element in the given schema and catalog.
+     *
+     * @param  catalog     the catalog, or {@code null} if none.
+     * @param  schema      the schema, or {@code null} if none.
+     * @param  identifier  the identifier to append.
+     * @return this builder, for method call chaining.
+     */
+    public final SQLBuilder appendIdentifier(final String catalog, String schema, final String identifier) {
+        if (catalog != null) {
+            appendIdentifier(catalog);
+            if (schema == null) {
+                return appendIdentifier("").appendIdentifier(identifier);
+            }
+        }
+        return appendIdentifier(schema, identifier);
+    }
+
+    /**
      * Appends a value in a {@code SELECT} statement.
      * The {@code "="} string will be inserted before the value.
      *
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Analyzer.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Analyzer.java
index 74c802c..14de8f9 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Analyzer.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Analyzer.java
@@ -39,6 +39,7 @@ import org.apache.sis.internal.system.DefaultFactories;
 import org.apache.sis.storage.sql.SQLStore;
 import org.apache.sis.storage.DataStore;
 import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.InternalDataStoreException;
 import org.apache.sis.util.logging.WarningListeners;
 import org.apache.sis.util.resources.ResourceInternationalString;
 
@@ -268,24 +269,24 @@ final class Analyzer {
      * to another table. If a cyclic dependency is detected, then this method return
      * {@code null} for one of the tables.
      *
-     * @param  id            identification of the table to create.
-     * @param  name          the value of {@code id.getName(analyzer)}
-     *                       (as an argument for avoiding re-computation when already known by the caller).
-     * @param  isDependency  {@code false} if this table has been explicitly requested by the user,
-     *                       or {@code true} if this is a dependency discovered while analyzing.
+     * @param  id          identification of the table to create.
+     * @param  name        the value of {@code id.getName(analyzer)}
+     *                     (as an argument for avoiding re-computation when already known by the caller).
+     * @param  importedBy  if this table is imported by the foreigner keys of another table,
+     *                     the parent table. Otherwise {@code null}.
      * @return the table, or {@code null} if there is a cyclic dependency and the table of the given
      *         name is already in process of being created.
      */
-    final Table table(final TableReference id, final GenericName name, final boolean isDependency)
+    final Table table(final TableReference id, final GenericName name, final TableReference importedBy)
             throws SQLException, DataStoreException
     {
         Table table = tables.get(name);
         if (table == null && !tables.containsKey(name)) {
             tables.put(name, null);                       // Mark the feature as in process of being created.
-            table = new Table(this, id, isDependency);
+            table = new Table(this, id, importedBy);
             if (tables.put(name, table) != null) {
                 // Should never happen. If thrown, we have a bug (e.g. synchronization) in this package.
-                throw new DataStoreException(internalError());
+                throw new InternalDataStoreException(internalError());
             }
         }
         return table;
@@ -313,7 +314,10 @@ final class Analyzer {
      * Invoked after we finished to create all tables. This method flush the warnings
      * (omitting duplicated warnings), then returns all tables including dependencies.
      */
-    final Collection<Table> finish() {
+    final Collection<Table> finish() throws DataStoreException {
+        for (final Table table : tables.values()) {
+            table.setDeferredSearchTables(this, tables);
+        }
         for (final ResourceInternationalString warning : warnings) {
             final LogRecord record = warning.toLogRecord(Level.WARNING);
             record.setSourceClassName(SQLStore.class.getName());
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Database.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Database.java
index 62a3bd8..7996149 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Database.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Database.java
@@ -139,7 +139,7 @@ public final class Database {
         tableList = new ArrayList<>(tableNames.length);
         for (final TableReference reference : declared) {
             // Adds only the table explicitly required by the user.
-            tableList.add(analyzer.table(reference, reference.getName(analyzer), false));
+            tableList.add(analyzer.table(reference, reference.getName(analyzer), null));
         }
         /*
          * At this point we finished to create the table explicitly requested by the users.
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Features.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Features.java
index af6309e..26eddbe 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Features.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Features.java
@@ -18,6 +18,9 @@ package org.apache.sis.internal.sql.feature;
 
 import java.util.List;
 import java.util.ArrayList;
+import java.util.Map;
+import java.util.HashMap;
+import java.util.Collection;
 import java.util.Spliterator;
 import java.util.function.Consumer;
 import java.sql.Connection;
@@ -28,8 +31,10 @@ import java.sql.ResultSet;
 import java.sql.SQLException;
 import java.lang.reflect.Array;
 import org.apache.sis.internal.metadata.sql.SQLBuilder;
-import org.apache.sis.util.collection.WeakValueHashMap;
+import org.apache.sis.storage.InternalDataStoreException;
 import org.apache.sis.util.collection.BackingStoreException;
+import org.apache.sis.util.collection.WeakValueHashMap;
+import org.apache.sis.util.ArraysExt;
 
 // Branch-dependent imports
 import org.opengis.feature.Feature;
@@ -46,6 +51,11 @@ import org.opengis.feature.FeatureType;
  */
 final class Features implements Spliterator<Feature>, Runnable {
     /**
+     * An empty array of iterators, used when there is no dependency.
+     */
+    private static final Features[] EMPTY = new Features[0];
+
+    /**
      * The type of features to create.
      */
     private final FeatureType featureType;
@@ -59,22 +69,37 @@ final class Features implements Spliterator<Feature>, Runnable {
 
     /**
      * Name of the properties where are stored associations in feature instances.
+     * The length of this array shall be equal to the {@link #dependencies} array length.
+     * Imported or exported features read by {@code dependencies[i]} will be stored in
+     * the association named {@code associationNames[i]}.
      */
     private final String[] associationNames;
 
     /**
-     * The feature sets referenced through foreigner keys. The length of this array shall be the same as
-     * {@link #associationNames} array length. Imported features are index <var>i</var> will be stored in
-     * the association named {@code associationNames[i]}.
+     * Name of the property where to store the association that we can not handle with other {@link #dependencies}.
+     * This deferred association may exist because of circular dependency.
+     */
+    private final String deferredAssociation;
+
+    /**
+     * The feature sets referenced through foreigner keys, or {@link #EMPTY} if none.
+     * This includes the associations inferred from both the imported and exported keys.
+     * The first {@link #importCount} iterators are for imported keys, and the remaining
+     * iterators are for the exported keys.
      */
-    private final Features[] importedFeatures;
+    private final Features[] dependencies;
 
     /**
-     * Zero-based index of the first column to query for each {@link #importedFeatures}.
-     * The length of this array shall be one more than {@link #importedFeatures}, with
-     * the last value set to the zero-based index after the last column.
+     * Number of entries in {@link #dependencies} for {@link Relation.Direction#IMPORT}.
+     * The entries immediately following the first {@code importCount} entries are for
+     * {@link Relation.Direction#EXPORT}.
      */
-    private final int[] foreignerKeyIndices;
+    private final int importCount;
+
+    /**
+     * One-based indices of the columns to query for each {@link #dependencies} entry.
+     */
+    private final int[][] foreignerKeyIndices;
 
     /**
      * If this iterator returns only the feature matching some condition (typically a primary key value),
@@ -84,7 +109,9 @@ final class Features implements Spliterator<Feature>, Runnable {
     private final PreparedStatement statement;
 
     /**
-     * The result of executing the SQL query for a {@link Table}.
+     * The result of executing the SQL query for a {@link Table}. If {@link #statement} is null,
+     * then a single {@code ResultSet} is used for all the lifetime of this {@code Features} instance.
+     * Otherwise an arbitrary amount of {@code ResultSet}s may be created from the statement.
      */
     private ResultSet result;
 
@@ -114,65 +141,109 @@ final class Features implements Spliterator<Feature>, Runnable {
 
     /**
      * Creates a new iterator over the feature instances.
+     *
+     * @param table             the table for which we are creating an iterator.
+     * @param connection        connection to the database.
+     * @param attributeNames    value of {@link Table#attributeNames}:   where to store simple values.
+     * @param attributeColumns  value of {@link Table#attributeColumns}: often the same as attribute names.
+     * @param importedKeys      value of {@link Table#importedKeys}:     targets of this table foreign keys.
+     * @param exportedKeys      value of {@link Table#exportedKeys}:     foreigner keys of other tables.
+     * @param following         the relations that we are following. Used for avoiding never ending loop.
+     * @param noFollow          relation to not follow, or {@code null} if none.
      */
     Features(final Table table, final Connection connection, final String[] attributeNames, final String[] attributeColumns,
-             final Relation[] importedKeys, final Relation componentOf) throws SQLException
+             final Relation[] importedKeys, final Relation[] exportedKeys, final List<Relation> following, final Relation noFollow)
+             throws SQLException, InternalDataStoreException
     {
         this.featureType = table.featureType;
         this.attributeNames = attributeNames;
         final DatabaseMetaData metadata = connection.getMetaData();
-        estimatedSize = (componentOf == null) ? table.countRows(metadata, true) : 0;
+        estimatedSize = following.isEmpty() ? table.countRows(metadata, true) : 0;
         final SQLBuilder sql = new SQLBuilder(metadata, true).append("SELECT");
+        final Map<String,Integer> columnIndices = new HashMap<>();
         /*
          * Create a SELECT clause with all columns that are ordinary attributes.
          * Order matter, since 'Features' iterator will map the columns to the
          * attributes listed in the 'attributeNames' array in that order.
          */
-        int count = 0;
         for (String column : attributeColumns) {
-            if (count != 0) sql.append(',');
-            sql.append(' ').append(column);
-            count++;
+            appendColumn(sql, column, columnIndices);
         }
         /*
-         * Append columns required for all relations to other tables.
-         * A column appended here may duplicate a columns appended in above loop
-         * if the same column is used both as primary key and foreigner key.
+         * Collect information about associations in local arrays before to assign
+         * them to the final fields, because some array lengths may be adjusted.
          */
-        if (importedKeys != null) {
-            final int n = importedKeys.length;
-            associationNames    = new String[n];
-            importedFeatures    = new Features[n];
-            foreignerKeyIndices = new int[n + 1];
-            foreignerKeyIndices[0] = count;
-            for (int i=0; i<n;) {
-                final Relation dependency = importedKeys[i];
-                associationNames[i] = dependency.getPropertyName();
-                importedFeatures[i] = dependency.getSearchTable().features(connection, dependency);
-                for (final String column : dependency.getForeignerKeys()) {
-                    if (count != 0) sql.append(',');
-                    sql.append(' ').append(column);
-                    count++;
-                }
-                foreignerKeyIndices[++i] = count;
-            }
-        } else {
+        int importCount = (importedKeys != null) ? importedKeys.length : 0;
+        int exportCount = (exportedKeys != null) ? exportedKeys.length : 0;
+        int totalCount  = importCount + exportCount;
+        if (totalCount == 0) {
+            dependencies        = EMPTY;
             associationNames    = null;
-            importedFeatures    = null;
             foreignerKeyIndices = null;
+            deferredAssociation = null;
+        } else {
+            String deferredAssociation = null;
+            final Features[]     dependencies = new Features[totalCount];
+            final String[]   associationNames = new String  [totalCount];
+            final int[][] foreignerKeyIndices = new int     [totalCount][];
+            /*
+             * For each foreigner key to another table, append all columns of that foreigner key
+             * and the name of the single feature property where the association will be stored.
+             */
+            if (importCount != 0) {
+                importCount = 0;                                                    // We will recount.
+                for (final Relation dependency : importedKeys) {
+                    if (dependency != noFollow) {
+                        dependency.startFollowing(following);                       // Safety against never-ending recursivity.
+                        associationNames   [importCount] = dependency.propertyName;
+                        foreignerKeyIndices[importCount] = getColumnIndices(sql, dependency.getForeignerKeys(), columnIndices);
+                        dependencies       [importCount] = dependency.getSearchTable().features(connection, following, noFollow);
+                        dependency.endFollowing(following);
+                        importCount++;
+                    } else {
+                        deferredAssociation = dependency.propertyName;
+                    }
+                }
+            }
+            /*
+             * Create iterators for other tables that reference the primary keys of this table. For example
+             * if we have a "City" feature with attributes for the city name, population, etc. and a "Parks"
+             * feature referencing the city where the park is located, in order to populate the "City.parks"
+             * associations we need to iterate over all "Parks" rows referencing the city.
+             */
+            if (exportCount != 0) {
+                int i = importCount;
+                for (final Relation dependency : exportedKeys) {
+                    dependency.startFollowing(following);                   // Safety against never-ending recursivity.
+                    final Table foreigner  = dependency.getSearchTable();
+                    final Relation inverse = foreigner.getInverseOf(dependency, table.name);
+                    associationNames   [i] = dependency.propertyName;
+                    foreignerKeyIndices[i] = getColumnIndices(sql, dependency.getForeignerKeys(), columnIndices);
+                    dependencies       [i] = foreigner.features(connection, following, inverse);
+                    dependency.endFollowing(following);
+                    i++;
+                }
+            }
+            totalCount = importCount + exportCount;
+            this.dependencies        = ArraysExt.resize(dependencies,        totalCount);
+            this.associationNames    = ArraysExt.resize(associationNames,    totalCount);
+            this.foreignerKeyIndices = ArraysExt.resize(foreignerKeyIndices, totalCount);
+            this.deferredAssociation = deferredAssociation;
         }
+        this.importCount = importCount;
         /*
          * Create a Statement if we don't need any condition, or a PreparedStatement if we need to add
          * a "WHERE" clause. In the later case, we will cache the features already created if there is
          * a possibility that many rows reference the same feature instance.
          */
-        sql.append(" FROM ").appendIdentifier(table.schema, table.table);
-        if (componentOf == null) {
+        sql.append(" FROM ").appendIdentifier(table.name.catalog, table.name.schema, table.name.table);
+        if (following.isEmpty()) {
             statement = null;
             instances = null;       // A future SIS version could use the map opportunistically if it exists.
             keyComponentClass = null;
             result = connection.createStatement().executeQuery(sql.toString());
         } else {
+            final Relation componentOf = following.get(following.size() - 1);
             String separator = " WHERE ";
             for (String primaryKey : componentOf.getSearchColumns()) {
                 sql.append(separator).append(primaryKey).append("=?");
@@ -184,7 +255,7 @@ final class Features implements Spliterator<Feature>, Runnable {
              * in which case 'table.primaryKeyClass' should never be null. This assumption may not
              * hold if the relation has been defined by DatabaseMetaData.getCrossReference(…) instead.
              */
-            if (componentOf.isFullKey()) {
+            if (componentOf.useFullKey()) {
                 instances = table.instanceForPrimaryKeys();
                 keyComponentClass = table.primaryKeyClass.getComponentType();
             } else {
@@ -195,6 +266,35 @@ final class Features implements Spliterator<Feature>, Runnable {
     }
 
     /**
+     * Appends a columns in the given builder and remember the column indices.
+     * An exception is thrown if the column has already been added (should never happen).
+     */
+    private static int appendColumn(final SQLBuilder sql, final String column,
+            final Map<String,Integer> columnIndices) throws InternalDataStoreException
+    {
+        int columnCount = columnIndices.size();
+        if (columnCount != 0) sql.append(',');
+        sql.append(' ').append(column);
+        if (columnIndices.put(column, ++columnCount) == null) return columnCount;
+        throw new InternalDataStoreException(Resources.format(Resources.Keys.DuplicatedColumn_1, column));
+    }
+
+    /**
+     * Computes the 1-based indices of given columns, adding the columns in the given builder if necessary.
+     */
+    private static int[] getColumnIndices(final SQLBuilder sql, final Collection<String> columns,
+            final Map<String,Integer> columnIndices) throws InternalDataStoreException
+    {
+        int i = 0;
+        final int[] indices = new int[columns.size()];
+        for (final String column : columns) {
+            final Integer pos = columnIndices.get(column);
+            indices[i++] = (pos != null) ? pos : appendColumn(sql, column, columnIndices);
+        }
+        return indices;
+    }
+
+    /**
      * Returns an array of the given length capable to hold the identifier,
      * or {@code null} if there is no need for an array.
      */
@@ -255,6 +355,10 @@ final class Features implements Spliterator<Feature>, Runnable {
     /**
      * Gives at least the next feature to the given consumer.
      * Gives all remaining features if {@code all} is {@code true}.
+     *
+     * @param  action  the action to execute for each {@link Feature} instances fetched by this method.
+     * @param  all     {@code true} for reading all remaining feature instances, or {@code false} for only the next one.
+     * @return {@code true} if we have read an instance and {@code all} is {@code false} (so there is maybe other instances).
      */
     private boolean fetch(final Consumer<? super Feature> action, final boolean all) throws SQLException {
         while (result.next()) {
@@ -265,27 +369,44 @@ final class Features implements Spliterator<Feature>, Runnable {
                     feature.setPropertyValue(attributeNames[i], value);
                 }
             }
-            if (importedFeatures != null) {
-                for (int i=0; i < importedFeatures.length; i++) {
-                    final Features dependency = importedFeatures[i];
-                    int columnIndice = foreignerKeyIndices[i];
-                    final int columnCount = foreignerKeyIndices[i+1] - columnIndice;
+            for (int i=0; i < dependencies.length; i++) {
+                final Features dependency = dependencies[i];
+                final int[] columnIndices = foreignerKeyIndices[i];
+                final Object value;
+                if (i < importCount) {
                     /*
+                     * Relation.Direction.IMPORT: this table contains the foreigner keys.
+                     *
                      * If the foreigner key uses only one column, we will store the foreigner key value
                      * in the 'key' variable without creating array. But if the foreigner key uses more
                      * than one column, then we need to create an array holding all values.
                      */
-                    final Object keys = dependency.identifierArray(columnCount);
                     Object key = null;
-                    for (int p=0; p < columnCount;) {
-                        key = result.getObject(++columnIndice);
+                    final Object keys = dependency.identifierArray(columnIndices.length);
+                    for (int p=0; p < columnIndices.length;) {
+                        key = result.getObject(columnIndices[p]);
                         if (keys != null) Array.set(keys, p, key);
                         dependency.statement.setObject(++p, key);
                     }
                     if (keys != null) key = keys;
-                    final Object value = dependency.fetchReferenced(key);
-                    feature.setPropertyValue(associationNames[i], value);
+                    value = dependency.fetchReferenced(key, null);
+                } else {
+                    /*
+                     * Relation.Direction.EXPORT: another table references this table.
+                     *
+                     * 'key' must stay null because we do not cache those dependencies.
+                     * The reason is that this direction can return a lot of instances,
+                     * contrarily to Direction.IMPORT which return only one instance.
+                     * Furthermore instances fetched from Direction.EXPORT can not be
+                     * shared by feature instances, so caching would be useless here.
+                     */
+                    for (int p=0; p < columnIndices.length;) {
+                        final Object k = result.getObject(columnIndices[p]);
+                        dependency.statement.setObject(++p, k);
+                    }
+                    value = dependency.fetchReferenced(null, feature);
                 }
+                feature.setPropertyValue(associationNames[i], value);
             }
             action.accept(feature);
             if (!all) return true;
@@ -297,8 +418,12 @@ final class Features implements Spliterator<Feature>, Runnable {
      * Executes the current {@link #statement} and stores all features in a list.
      * Returns {@code null} if there is no feature, or returns the feature instance
      * if there is only one such instance, or returns a list of features otherwise.
+     *
+     * @param  key    the key to use for referencing the feature in the cache, or {@code null} for no caching.
+     * @param  owner  if the features to fetch are components of another feature, that container feature instance.
+     * @return the feature as a singleton {@code Feature} or as a {@code Collection<Feature>}.
      */
-    private Object fetchReferenced(final Object key) throws SQLException {
+    private Object fetchReferenced(final Object key, final Feature owner) throws SQLException {
         if (key != null) {
             Object existing = instances.get(key);
             if (existing != null) {
@@ -312,6 +437,11 @@ final class Features implements Spliterator<Feature>, Runnable {
         } finally {
             result = null;
         }
+        if (owner != null && deferredAssociation != null) {
+            for (final Feature feature : features) {
+                feature.setPropertyValue(deferredAssociation, owner);
+            }
+        }
         Object feature;
         switch (features.size()) {
             case 0:  feature = null; break;
@@ -347,7 +477,7 @@ final class Features implements Spliterator<Feature>, Runnable {
             try (Connection c = s.getConnection()) {
                 r.close();      // Implied by s.close() according JDBC javadoc, but we are paranoiac.
                 s.close();
-                for (final Features dependency : importedFeatures) {
+                for (final Features dependency : dependencies) {
                     dependency.close();
                 }
             }
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Relation.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Relation.java
index 22f6290..140326e 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Relation.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Relation.java
@@ -17,6 +17,7 @@
 package org.apache.sis.internal.sql.feature;
 
 import java.util.Arrays;
+import java.util.List;
 import java.util.Map;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
@@ -28,9 +29,11 @@ import java.sql.SQLException;
 import java.sql.DatabaseMetaData;
 import org.apache.sis.internal.util.CollectionsExt;
 import org.apache.sis.internal.metadata.sql.Reflection;
-import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.DataStoreContentException;
+import org.apache.sis.storage.InternalDataStoreException;
 import org.apache.sis.util.collection.TreeTable;
+import org.apache.sis.util.resources.Errors;
 import org.apache.sis.util.Debug;
 
 
@@ -115,6 +118,24 @@ final class Relation extends TableReference {
         }
     }
 
+    /*
+     * Whether this relation is importing from or exporting to another table.
+     *
+     * This field is not stored because it is implied by the list where the relation is stored:
+     * Table.importedKeys or Table.exportedKeys. Instead this information is passed in argument
+     * when needed, with a "this.direction" comment.
+     */
+//  private final Direction direction;
+
+    /*
+     * The table that contains this relation.
+     *
+     * This field is not stored because it is implied by the list where the relation is stored:
+     * Table.importedKeys or Table.exportedKeys. Instead this information is passed in argument
+     * when needed, with a "this.owner" comment.
+     */
+//  private final Table owner;
+
     /**
      * The columns of the other table that constitute a primary or foreigner key. Keys are the columns of the
      * other table and values are columns of the table containing this {@code Relation}.
@@ -129,17 +150,18 @@ final class Relation extends TableReference {
 
     /**
      * The name of the feature property where the association to {@link #searchTable} table will be stored.
+     * Shall be set exactly once.
      */
-    private String propertyName;
+    String propertyName;
 
     /**
      * Whether the {@link #columns} map include all primary key columns. This field is set to {@code false}
      * if the foreigner key uses only a subset of the primary key columns, in which case the referenced rows
      * may not be unique.
      *
-     * @see #isFullKey()
+     * @see #useFullKey()
      */
-    private boolean isFullKey;
+    private boolean useFullKey;
 
     /**
      * Creates a new relation for an imported key. The given {@code ResultSet} must be positioned
@@ -184,46 +206,57 @@ final class Relation extends TableReference {
     }
 
     /**
+     * Invoked after construction for setting the name of the feature property of the enclosing table where to
+     * store association to the feature instances read from the {@linkplain #getSearchTable() search table}.
+     * If the foreigner key use exactly one column, we can use the name of that column. Otherwise we don't know
+     * which column has the most appropriate name (often there is none), so we fallback on the foreigner key name.
+     *
+     * @param  column  a foreigner key column.
+     * @param  count   number of names previously created from that column.
+     */
+    final void setPropertyName(final String column, final int count) {
+        if (columns.size() > 1) {
+            propertyName = freeText;        // Foreigner key name (may be null).
+        }
+        if (propertyName == null) {
+            propertyName = (count == 0) ? column : column + '-' + count;
+        }
+    }
+
+    /**
      * Invoked after construction for setting the table identified by {@link #catalog}, {@link #schema}
      * and {@link #table} names. Shall be invoked exactly once.
      *
      * @param  search       the other table containing the primary key ({@link Direction#IMPORT})
      *                      or the foreigner key ({@link Direction#EXPORT}) of this relation.
-     * @param  property     name of the property of the enclosing {@code Table.featureType}
-     *                      where to store the feature instances read from the other table.
      * @param  primaryKeys  the primary key columns of the relation. May be the primary key columns of this table
      *                      or the primary key columns of the other table, depending on {@link Direction}.
-     * @param  direction    the direction of this {@code Relation}.
+     * @param  direction    {@code this.direction} (see comment in field declarations).
      */
-    final void setSearchTable(final Analyzer analyzer, final Table search, final String property,
-                              final String[] primaryKeys, final Direction direction) throws DataStoreException
+    final void setSearchTable(final Analyzer analyzer, final Table search, final String[] primaryKeys,
+                              final Direction direction) throws DataStoreException
     {
         /*
-         * Sanity check (could be assertion): name of the given table
-         * must match the name expected by this relation.
+         * Sanity check (could be assertion): name of the given table must match
+         * the name expected by this relation. This check below should never fail.
          */
-        String expected, actual;    // Identity comparison okay because of Analyzer.getUniqueString(…)
-        if ((expected = table)  != (actual = search.table)  ||
-            (expected = schema) != (actual = search.schema) || searchTable != null)
-        {
-            String message = analyzer.internalError();                                  // Should never happen.
-            if (expected != actual) message += ' ' + actual + " ≠ " + expected;
-            throw new DataStoreException(message);
+        final boolean isDefined = (searchTable != null);
+        if (isDefined || !equals(search.name)) {
+            throw new InternalDataStoreException(isDefined ? analyzer.internalError() : super.toString());
         }
         /*
-         * Store the specified table and property name, and verify if this relation
+         * Store the specified table and verify if this relation
          * contains all columns required by the primary key.
          */
-        searchTable  = search;
-        propertyName = property;
+        searchTable = search;
         final Collection<String> referenced;        // Primary key components referenced by this relation.
         switch (direction) {
             case IMPORT: referenced = columns.keySet(); break;
             case EXPORT: referenced = columns.values(); break;
             default: throw new AssertionError(direction);
         }
-        isFullKey = referenced.containsAll(Arrays.asList(primaryKeys));
-        if (isFullKey && getKeySize() >= 2) {
+        useFullKey = referenced.containsAll(Arrays.asList(primaryKeys));
+        if (useFullKey && columns.size() >= 2) {
             /*
              * Sort the columns in the order expected by the primary key.  This is required because we need
              * a consistent order in the cache provided by Table.instanceForPrimaryKeys() even if different
@@ -253,7 +286,7 @@ final class Relation extends TableReference {
                     }
                 }
                 if (key == null || value == null || columns.put(key, value) != null) {
-                    throw new DataStoreException(analyzer.internalError());
+                    throw new InternalDataStoreException(analyzer.internalError());
                 }
             }
             if (!copy.isEmpty()) {
@@ -271,11 +304,18 @@ final class Relation extends TableReference {
      *
      * <pre>{@code SELECT * FROM <search table> WHERE <search columns> = ...}</pre>
      */
-    final Table getSearchTable() {
+    final Table getSearchTable() throws InternalDataStoreException {
         if (searchTable != null) {
             return searchTable;
         }
-        throw new IllegalStateException(super.toString());              // Should not happen.
+        throw new InternalDataStoreException(super.toString());                 // Should not happen.
+    }
+
+    /**
+     * Returns whether {@link #setSearchTable setSearchTable(…)} has been invoked.
+     */
+    final boolean isSearchTableDefined() {
+        return searchTable != null;
     }
 
     /**
@@ -299,26 +339,54 @@ final class Relation extends TableReference {
     }
 
     /**
-     * Returns the number of columns used by the foreigner key.
+     * Returns {@code true} if this relation includes all required primary key columns. Returns {@code false}
+     * if the foreigner key uses only a subset of the primary key columns, in which case the referenced rows
+     * may not be unique.
      */
-    final int getKeySize() {
-        return columns.size();
+    final boolean useFullKey() {
+        return useFullKey;
     }
 
     /**
-     * Returns {@code true} if this relation includes all required primary key columns. Returns {@code false}
-     * if the foreigner key uses only a subset of the primary key columns, in which case the referenced rows
-     * may not be unique.
+     * Returns {@code true} if this relation is the inverse of the given relation.
+     * If two relations are inverse, then following those relations recursively would result in an infinite loop.
+     *
+     * <p>This method tests only the column names; the table names shall be verified by the caller.
+     * Table names can be verified as below (see comment in field declarations for meaning of "owner"):</p>
+     * <ul>
+     *   <li>{@code this.equals(other.owner.name)}: the target of this relation is the source of other relation.</li>
+     *   <li>{@code this.owner.name.equals(other)}: the source of this relation is the target of other relation.</li>
+     * </ul>
+     *
+     * @param  other  the other relation to check for inverse relationship.
      */
-    final boolean isFullKey() {
-        return isFullKey;
+    final boolean isInverseOf(final Relation other) {
+        return columns.size() == other.columns.size() &&
+               columns.keySet().containsAll(other.columns.values()) &&
+               other.columns.keySet().containsAll(columns.values());
     }
 
     /**
-     * Returns the name of the feature property where the association to {@link #searchTable} table will be stored.
+     * Adds this relation to the given list, making sure that the relation has not already been added.
+     * The check for previous existence of {@code this} relation is for preventing infinite recursivity.
      */
-    final String getPropertyName() {
-        return propertyName;
+    final void startFollowing(final List<Relation> following) throws InternalDataStoreException {
+        for (int i = following.size(); --i >= 0;) {
+            if (following.get(i) == this) {
+                throw new InternalDataStoreException(Errors.format(Errors.Keys.CircularReference));
+            }
+        }
+        following.add(this);
+    }
+
+    /**
+     * Removes this relation from the given list, making sure that this relation was at the tail.
+     */
+    final void endFollowing(final List<Relation> following) throws InternalDataStoreException {
+        final int last = following.size() - 1;
+        if (last < 0 || following.remove(last) != this) {
+            throw new InternalDataStoreException();
+        }
     }
 
     /**
@@ -329,7 +397,11 @@ final class Relation extends TableReference {
      */
     @Debug
     void appendTo(final TreeTable.Node parent, final String arrow) {
-        final TreeTable.Node node = newChild(parent, freeText);
+        String label = super.toString();
+        if (freeText != null) {
+            label = freeText + " ⟶ " + label;
+        }
+        final TreeTable.Node node = newChild(parent, label);
         for (final Map.Entry<String,String> e : columns.entrySet()) {
             newChild(node, e.getValue() + arrow + e.getKey());
         }
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Table.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Table.java
index 2a73fef..70a6b73 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Table.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Table.java
@@ -22,7 +22,6 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.stream.Stream;
 import java.util.stream.StreamSupport;
 import java.sql.DatabaseMetaData;
@@ -36,13 +35,14 @@ import org.apache.sis.feature.builder.AttributeRole;
 import org.apache.sis.feature.builder.AttributeTypeBuilder;
 import org.apache.sis.feature.builder.AssociationRoleBuilder;
 import org.apache.sis.feature.builder.FeatureTypeBuilder;
-import org.apache.sis.storage.DataStoreContentException;
 import org.apache.sis.internal.feature.Geometries;
+import org.apache.sis.storage.DataStoreException;
+import org.apache.sis.storage.DataStoreContentException;
+import org.apache.sis.storage.InternalDataStoreException;
 import org.apache.sis.internal.metadata.sql.Reflection;
 import org.apache.sis.internal.metadata.sql.SQLUtilities;
 import org.apache.sis.internal.storage.AbstractFeatureSet;
 import org.apache.sis.internal.util.CollectionsExt;
-import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.util.collection.WeakValueHashMap;
 import org.apache.sis.util.collection.TreeTable;
 import org.apache.sis.util.CharSequences;
@@ -84,9 +84,9 @@ final class Table extends AbstractFeatureSet {
     final FeatureType featureType;
 
     /**
-     * The name in the database of this {@code Table} object, as a table name and a schema name.
+     * The name in the database of this {@code Table} object, together with its schema and catalog.
      */
-    final String schema, table;
+    final TableReference name;
 
     /**
      * Name of attributes in feature instances, excluding operations and associations to other tables.
@@ -147,20 +147,19 @@ final class Table extends AbstractFeatureSet {
      * The table is identified by {@code id}, which contains a (catalog, schema, name) tuple.
      * The catalog and schema parts are optional and can be null, but the table is mandatory.
      *
-     * @param  analyzer      helper functions, e.g. for converting SQL types to Java types.
-     * @param  id            the catalog, schema and table name of the table to analyze.
-     * @param  isDependency  {@code false} if this table has been explicitly requested by the user,
-     *                       or {@code true} if this is a dependency discovered while analyzing.
+     * @param  analyzer    helper functions, e.g. for converting SQL types to Java types.
+     * @param  id          the catalog, schema and table name of the table to analyze.
+     * @param  importedBy  if this table is imported by the foreigner keys of another table,
+     *                     the parent table. Otherwise {@code null}.
      */
-    Table(final Analyzer analyzer, final TableReference id, final boolean isDependency)
+    Table(final Analyzer analyzer, final TableReference id, final TableReference importedBy)
             throws SQLException, DataStoreException
     {
         super(analyzer.listeners);
         this.source = analyzer.source;
-        this.schema = id.schema;
-        this.table  = id.table;
-        final String tableEsc  = analyzer.escape(table);
-        final String schemaEsc = analyzer.escape(schema);
+        this.name   = id;
+        final String tableEsc  = analyzer.escape(id.table);
+        final String schemaEsc = analyzer.escape(id.schema);
         /*
          * Get a list of primary keys. We need to know them before to create the attributes,
          * in order to detect which attributes are used as components of Feature identifiers.
@@ -203,16 +202,14 @@ final class Table extends AbstractFeatureSet {
                 }
             } while (!reflect.isClosed());
         }
-        final List<Relation> exportedKeys;
-        if (isDependency) {
-            exportedKeys = Collections.emptyList();
-        } else {
-            exportedKeys = new ArrayList<>();
-            try (ResultSet reflect = analyzer.metadata.getExportedKeys(id.catalog, id.schema, id.table)) {
-                if (reflect.next()) do {
-                    exportedKeys.add(new Relation(analyzer, Relation.Direction.EXPORT, reflect));
-                } while (!reflect.isClosed());
-            }
+        final List<Relation> exportedKeys = new ArrayList<>();
+        try (ResultSet reflect = analyzer.metadata.getExportedKeys(id.catalog, id.schema, id.table)) {
+            if (reflect.next()) do {
+                final Relation export = new Relation(analyzer, Relation.Direction.EXPORT, reflect);
+                if (!export.equals(importedBy)) {
+                    exportedKeys.add(export);
+                }
+            } while (!reflect.isClosed());
         }
         /*
          * For each column in the table that is not a foreigner key, create an AttributeType of the same name.
@@ -306,28 +303,22 @@ final class Table extends AbstractFeatureSet {
                     for (final Relation dependency : dependencies) {
                         if (dependency != null) {
                             final GenericName typeName = dependency.getName(analyzer);
-                            final Table table = analyzer.table(dependency, typeName, true);
+                            final Table table = analyzer.table(dependency, typeName, id);
                             /*
                              * Use the column name as the association name, provided that the foreigner key
                              * use only that column. If the foreigner key use more than one column, then we
                              * do not know which column describes better the association (often there is none).
                              * In such case we use the foreigner key name as a fallback.
                              */
-                            String propertyName = null;
-                            if (dependency.getKeySize() > 1) {
-                                propertyName = dependency.freeText;             // Foreigner key name (may be null).
-                            }
-                            if (propertyName == null) {
-                                propertyName = (count++ == 0) ? column : column + '-' + count;
-                            }
+                            dependency.setPropertyName(column, count++);
                             final AssociationRoleBuilder association;
                             if (table != null) {
-                                dependency.setSearchTable(analyzer, table, propertyName, table.primaryKeys, Relation.Direction.IMPORT);
+                                dependency.setSearchTable(analyzer, table, table.primaryKeys, Relation.Direction.IMPORT);
                                 association = feature.addAssociation(table.featureType);
                             } else {
                                 association = feature.addAssociation(typeName);     // May happen in case of cyclic dependency.
                             }
-                            association.setName(propertyName);
+                            association.setName(dependency.propertyName);
                             if (!mandatory) {
                                 association.setMinimumOccurs(0);
                             }
@@ -369,10 +360,11 @@ final class Table extends AbstractFeatureSet {
                 while (feature.isNameUsed(propertyName)) {
                     propertyName = base + '-' + ++count;
                 }
-                final Table table = analyzer.table(dependency, typeName, true);
+                dependency.propertyName = propertyName;
+                final Table table = analyzer.table(dependency, typeName, null);   // 'null' because exported, not imported.
                 final AssociationRoleBuilder association;
                 if (table != null) {
-                    dependency.setSearchTable(analyzer, table, propertyName, this.primaryKeys, Relation.Direction.EXPORT);
+                    dependency.setSearchTable(analyzer, table, this.primaryKeys, Relation.Direction.EXPORT);
                     association = feature.addAssociation(table.featureType);
                 } else {
                     association = feature.addAssociation(typeName);     // May happen in case of cyclic dependency.
@@ -433,6 +425,49 @@ final class Table extends AbstractFeatureSet {
     }
 
     /**
+     * Sets the search tables on all {@link Relation} instances for which this operation has been deferred.
+     * This happen when a table could not be obtained because of circular dependency. This method is invoked
+     * after all tables have been created in order to fill such holes.
+     *
+     * @param  tables  all tables created.
+     */
+    final void setDeferredSearchTables(final Analyzer analyzer, final Map<GenericName,Table> tables) throws DataStoreException {
+        for (final Relation.Direction direction : Relation.Direction.values()) {
+            final Relation[] relations;
+            switch (direction) {
+                case IMPORT: relations = importedKeys; break;
+                case EXPORT: relations = exportedKeys; break;
+                default: continue;
+            }
+            if (relations != null) {
+                for (final Relation relation : relations) {
+                    if (!relation.isSearchTableDefined()) {
+                        // A ClassCastException below would be a bug since 'relation.propertyName' shall be for an association.
+                        FeatureAssociationRole association = (FeatureAssociationRole) featureType.getProperty(relation.propertyName);
+                        final Table table = tables.get(association.getValueType().getName());
+                        if (table == null) {
+                            throw new InternalDataStoreException(association.toString());
+                        }
+                        final String[] referenced;
+                        switch (direction) {
+                            case IMPORT: referenced = table.primaryKeys; break;
+                            case EXPORT: referenced =  this.primaryKeys; break;
+                            default: throw new AssertionError(direction);
+                        }
+                        relation.setSearchTable(analyzer, table, referenced, direction);
+                    }
+                }
+            }
+        }
+    }
+
+
+    // ────────────────────────────────────────────────────────────────────────────────────────
+    //     End of table construction. Next methods are for visualizing the table structure.
+    // ────────────────────────────────────────────────────────────────────────────────────────
+
+
+    /**
      * Appends all children to the given parent. The children are added under the given node.
      * If the children array is null, then this method does nothing.
      *
@@ -474,6 +509,12 @@ final class Table extends AbstractFeatureSet {
         return TableReference.toString(this, (n) -> appendTo(n));
     }
 
+
+    // ────────────────────────────────────────────────────────────────────────────────────────
+    //     End of table structure visualization. Next methods are for fetching features.
+    // ────────────────────────────────────────────────────────────────────────────────────────
+
+
     /**
      * Returns the feature type inferred from the database structure analysis.
      */
@@ -483,6 +524,25 @@ final class Table extends AbstractFeatureSet {
     }
 
     /**
+     * If this table imports the inverse of the given relation, returns the imported relation.
+     * Otherwise returns {@code null}. This method is used for preventing infinite recursivity.
+     *
+     * @param  exported       the relation exported by another table.
+     * @param  exportedOwner  {@code exported.owner.name}: table that contains the {@code exported} relation.
+     * @return the inverse of the given relation, or {@code null} if none.
+     */
+    final Relation getInverseOf(final Relation exported, final TableReference exportedOwner) {
+        if (importedKeys != null && name.equals(exported)) {
+            for (final Relation relation : importedKeys) {
+                if (relation.equals(exportedOwner) && relation.isInverseOf(exported)) {
+                    return relation;
+                }
+            }
+        }
+        return null;
+    }
+
+    /**
      * Returns a cache for fetching feature instances by identifier. The map is created when this method is
      * first invoked. Keys are primary key values, typically as {@code String} or {@code Integer} instances
      * or arrays of those if the keys use more than one column. Values are usually {@code Feature} instances,
@@ -537,7 +597,7 @@ final class Table extends AbstractFeatureSet {
         Connection connection = null;
         try {
             connection = source.getConnection();
-            final Features iter = features(connection, null);
+            final Features iter = features(connection, new ArrayList<>(), null);
             return StreamSupport.stream(iter, parallel).onClose(iter);
         } catch (SQLException cause) {
             ex = new DataStoreException(Exceptions.unwrap(cause));
@@ -552,8 +612,14 @@ final class Table extends AbstractFeatureSet {
 
     /**
      * Returns an iterator over the features.
+     *
+     * @param connection  connection to the database.
+     * @param following   the relations that we are following. Used for avoiding never ending loop.
+     * @param noFollow    relation to not follow, or {@code null} if none.
      */
-    final Features features(final Connection connection, final Relation componentOf) throws SQLException {
-        return new Features(this, connection, attributeNames, attributeColumns, importedKeys, componentOf);
+    final Features features(final Connection connection, final List<Relation> following, final Relation noFollow)
+            throws SQLException, InternalDataStoreException
+    {
+        return new Features(this, connection, attributeNames, attributeColumns, importedKeys, exportedKeys, following, noFollow);
     }
 }
diff --git a/storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/SQLStoreTest.java b/storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/SQLStoreTest.java
index 48aa069..649dc06 100644
--- a/storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/SQLStoreTest.java
+++ b/storage/sis-sqlstore/src/test/java/org/apache/sis/storage/sql/SQLStoreTest.java
@@ -18,6 +18,9 @@ package org.apache.sis.storage.sql;
 
 import java.util.Map;
 import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Arrays;
+import java.util.Collection;
 import java.util.stream.Stream;
 import org.apache.sis.storage.FeatureSet;
 import org.apache.sis.storage.StorageConnector;
@@ -25,7 +28,7 @@ import org.apache.sis.test.sql.TestDatabase;
 import org.apache.sis.test.TestCase;
 import org.junit.Test;
 
-import static org.opengis.test.Assert.*;
+import static org.apache.sis.test.Assert.*;
 
 // Branch-dependent imports
 import org.opengis.feature.Feature;
@@ -57,28 +60,35 @@ public final strictfp class SQLStoreTest extends TestCase {
     private Feature canada;
 
     /**
-     * Tests reading an existing schema. The schema is created and populated by the {@code Features.sql} script.
+     * Tests on PostgreSQL.
      *
      * @throws Exception if an error occurred while testing the database.
      */
     @Test
-    public void testReadStructure() throws Exception {
-        try (TestDatabase tmp = TestDatabase.createOnPostgreSQL("features", true)) {
+    public void testOnPostgreSQL() throws Exception {
+        test(TestDatabase.createOnPostgreSQL("features", true));
+    }
+
+    /**
+     * Tests reading an existing schema. The schema is created and populated by the {@code Features.sql} script.
+     */
+    private void test(final TestDatabase database) throws Exception {
+        try (TestDatabase tmp = database) {
             tmp.executeSQL(SQLStoreTest.class, "Features.sql");
             try (SQLStore store = new SQLStore(new SQLStoreProvider(), new StorageConnector(tmp.source),
                     SQLStoreProvider.createTableName(null, "features", "Cities")))
             {
                 final FeatureSet cities = (FeatureSet) store.findResource("Cities");
                 verifyFeatureType(cities.getType(),
-                        new String[] {"sis:identifier", "pk:country", "country",   "native_name", "translation", "population",  "parks"},
-                        new Object[] {null,             String.class, "Countries", String.class,  String.class,  Integer.class, "Parks"});
+                        new String[] {"sis:identifier", "pk:country", "country",   "native_name", "english_name", "population",  "parks"},
+                        new Object[] {null,             String.class, "Countries", String.class,  String.class,   Integer.class, "Parks"});
 
                 verifyFeatureType(((FeatureSet) store.findResource("Countries")).getType(),
                         new String[] {"sis:identifier", "code",       "native_name"},
                         new Object[] {null,             String.class, String.class});
 
                 verifyFeatureType(((FeatureSet) store.findResource("Parks")).getType(),
-                        new String[] {"sis:identifier", "pk:country", "FK_City", "city",       "native_name", "translation"},
+                        new String[] {"sis:identifier", "pk:country", "FK_City", "city",       "native_name", "english_name"},
                         new Object[] {null,             String.class, "Cities",  String.class, String.class,  String.class});
 
                 try (Stream<Feature> features = cities.features(false)) {
@@ -123,38 +133,43 @@ public final strictfp class SQLStoreTest extends TestCase {
      */
     private void verifyContent(final Feature feature) {
         final String city = feature.getPropertyValue("native_name").toString();
-        final String country, countryName, cityLatin;
+        final String country, countryName, englishName;
+        final String[] parks;
         final int population;
         boolean isCanada = false;
         switch (city) {
             case "東京": {
-                cityLatin   = "Tōkyō";
+                englishName = "Tōkyō";
                 country     = "JPN";
                 countryName = "日本";
                 population  = 13622267;         // In 2016.
+                parks       = new String[] {"Yoyogi-kōen", "Shinjuku Gyoen"};
                 break;
             }
             case "Paris": {
-                cityLatin   = "Paris";
+                englishName = "Paris";
                 country     = "FRA";
                 countryName = "France";
                 population  = 2206488;          // In 2017.
+                parks       = new String[] {"Tuileries Garden", "Luxembourg Garden"};
                 break;
             }
             case "Montréal": {
-                cityLatin   = "Montreal";
+                englishName = "Montreal";
                 country     = "CAN";
                 countryName = "Canada";
                 population  = 1704694;          // In 2016.
                 isCanada    = true;
+                parks       = new String[] {"Mount Royal"};
                 break;
             }
             case "Québec": {
-                cityLatin   = "Quebec";
+                englishName = "Quebec";
                 country     = "CAN";
                 countryName = "Canada";
                 population  = 531902;           // In 2016.
                 isCanada    = true;
+                parks = new String[] {};
                 break;
             }
             default: {
@@ -162,16 +177,18 @@ public final strictfp class SQLStoreTest extends TestCase {
                 return;
             }
         }
-        // Attributes
+        /*
+         * Verify attributes. They are the easiest properties to read.
+         */
         assertEquals("pk:country",     country,              feature.getPropertyValue("pk:country"));
         assertEquals("sis:identifier", country + ':' + city, feature.getPropertyValue("sis:identifier"));
-        assertEquals("translation",    cityLatin,            feature.getPropertyValue("translation"));
+        assertEquals("english_name",   englishName,          feature.getPropertyValue("english_name"));
         assertEquals("population",     population,           feature.getPropertyValue("population"));
-
-        // Associations
+        /*
+         * Associations using Relation.Direction.IMPORT.
+         * Those associations should be cached; we verify with "Canada" case.
+         */
         assertEquals("country", countryName, getIndirectPropertyValue(feature, "country", "native_name"));
-
-        // Caching
         if (isCanada) {
             final Feature f = (Feature) feature.getPropertyValue("country");
             if (canada == null) {
@@ -181,6 +198,28 @@ public final strictfp class SQLStoreTest extends TestCase {
             }
         }
         countryCount.merge(country, 1, (o, n) -> n+1);
+        /*
+         * Associations using Relation.Direction.EXPORT.
+         * Contrarily to the IMPORT case, those associations can contain many values.
+         */
+        final Collection<?> actualParks = (Collection<?>) feature.getPropertyValue("parks");
+        assertNotNull("parks", actualParks);
+        assertEquals("parks.length", parks.length, actualParks.size());
+        final Collection<String> expectedParks = new HashSet<>(Arrays.asList(parks));
+        for (final Object park : actualParks) {
+            final Feature pf = (Feature) park;
+            final String npn = (String) pf.getPropertyValue("native_name");
+            final String epn = (String) pf.getPropertyValue("english_name");
+            assertNotNull("park.native_name",  npn);
+            assertNotNull("park.english_name", epn);
+            assertNotEquals("park.names", npn, epn);
+            assertTrue("park.english_name", expectedParks.remove(epn));
+            /*
+             * Verify the reverse association form Parks to Cities.
+             * This create a cyclic graph, but SQLStore is capable to handle it.
+             */
+            assertSame("City → Park → City", feature, pf.getPropertyValue("FK_City"));
+        }
     }
 
     /**
diff --git a/storage/sis-sqlstore/src/test/resources/org/apache/sis/storage/sql/Features.sql b/storage/sis-sqlstore/src/test/resources/org/apache/sis/storage/sql/Features.sql
index e33e61f..05364c4 100644
--- a/storage/sis-sqlstore/src/test/resources/org/apache/sis/storage/sql/Features.sql
+++ b/storage/sis-sqlstore/src/test/resources/org/apache/sis/storage/sql/Features.sql
@@ -21,7 +21,7 @@ CREATE TABLE features."Countries" (
 CREATE TABLE features."Cities" (
     country      CHARACTER(3)          NOT NULL,
     native_name  CHARACTER VARYING(20) NOT NULL,
-    translation  CHARACTER VARYING(20),
+    english_name CHARACTER VARYING(20),
     population   INTEGER,
 
     CONSTRAINT "PK_City"    PRIMARY KEY (country, native_name),
@@ -33,7 +33,7 @@ CREATE TABLE features."Parks" (
     country      CHARACTER(3)          NOT NULL,
     city         CHARACTER VARYING(20) NOT NULL,
     native_name  CHARACTER VARYING(20) NOT NULL,
-    translation  CHARACTER VARYING(20),
+    english_name CHARACTER VARYING(20),
 
     CONSTRAINT "PK_Park" PRIMARY KEY (country, city, native_name),
     CONSTRAINT "FK_City" FOREIGN KEY (country, city) REFERENCES features."Cities"(country, native_name) ON DELETE CASCADE
@@ -54,13 +54,13 @@ INSERT INTO features."Countries" (code, native_name) VALUES
     ('FRA', 'France'),
     ('JPN', '日本');
 
-INSERT INTO features."Cities" (country, native_name, translation, population) VALUES
+INSERT INTO features."Cities" (country, native_name, english_name, population) VALUES
     ('CAN', 'Montréal', 'Montreal', 1704694),       -- Population in 2016
     ('CAN', 'Québec',   'Quebec',    531902),       -- Population in 2016
     ('FRA', 'Paris',    'Paris',    2206488),       -- Population in 2017
     ('JPN', '東京',     'Tōkyō',   13622267);       -- Population in 2016
 
-INSERT INTO features."Parks" (country, city, native_name, translation) VALUES
+INSERT INTO features."Parks" (country, city, native_name, english_name) VALUES
     ('CAN', 'Montréal', 'Mont Royal',           'Mount Royal'),
     ('FRA', 'Paris',    'Jardin des Tuileries', 'Tuileries Garden'),
     ('FRA', 'Paris',    'Jardin du Luxembourg', 'Luxembourg Garden'),
diff --git a/storage/sis-storage/src/main/java/org/apache/sis/storage/InternalDataStoreException.java b/storage/sis-storage/src/main/java/org/apache/sis/storage/InternalDataStoreException.java
new file mode 100644
index 0000000..8d33919
--- /dev/null
+++ b/storage/sis-storage/src/main/java/org/apache/sis/storage/InternalDataStoreException.java
@@ -0,0 +1,71 @@
+/*
+ * 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;
+
+
+/**
+ * Thrown when an internal error occurred in a {@code DataStore} implementation.
+ * This error is not necessarily caused by an illegal data file;
+ * it is more likely caused by a bug in the implementation,
+ * for example when an inconsistent state is detected.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+public class InternalDataStoreException extends DataStoreException {
+    /**
+     * For cross-version compatibility.
+     */
+    private static final long serialVersionUID = -1955963337799113196L;
+
+    /**
+     * Creates an exception with no details message.
+     */
+    public InternalDataStoreException() {
+        super();
+    }
+
+    /**
+     * Creates an exception with the specified details message.
+     *
+     * @param message  the detail message.
+     */
+    public InternalDataStoreException(String message) {
+        super(message);
+    }
+
+    /**
+     * Creates an exception with the specified cause and no details message.
+     *
+     * @param cause  the cause for this exception.
+     */
+    public InternalDataStoreException(Throwable cause) {
+        super(cause);
+    }
+
+    /**
+     * Creates an exception with the specified details message and cause.
+     *
+     * @param message  the detail message.
+     * @param cause    the cause for this exception.
+     */
+    public InternalDataStoreException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}


Mime
View raw message