You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sis.apache.org by de...@apache.org on 2021/10/05 18:09:00 UTC

[sis] 03/03: Avoid recursivity with "exported" foreigner keys in addition of "imported" ones.

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 911a413919d06cd4a549a4365c461e02adb622d3
Author: Martin Desruisseaux <ma...@geomatys.com>
AuthorDate: Tue Oct 5 12:07:17 2021 +0200

    Avoid recursivity with "exported" foreigner keys in addition of "imported" ones.
---
 .../apache/sis/internal/sql/feature/Analyzer.java  |  16 +--
 .../sis/internal/sql/feature/FeatureAdapter.java   |   6 +-
 .../sis/internal/sql/feature/FeatureAnalyzer.java  |  12 +-
 .../sis/internal/sql/feature/QueryAnalyzer.java    |   2 +-
 .../apache/sis/internal/sql/feature/Relation.java  |  28 +++--
 .../sis/internal/sql/feature/SchemaModifier.java   |  14 ++-
 .../org/apache/sis/internal/sql/feature/Table.java |   8 +-
 .../sis/internal/sql/feature/TableAnalyzer.java    |  35 ++++--
 .../sis/internal/sql/feature/package-info.java     |   2 +-
 .../org/apache/sis/storage/sql/package-info.java   |   2 +-
 .../org/apache/sis/storage/sql/SQLStoreTest.java   | 129 ++++++++++++++-------
 11 files changed, 168 insertions(+), 86 deletions(-)

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 5b46252..11ae100 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
@@ -46,7 +46,7 @@ import org.apache.sis.util.resources.ResourceInternationalString;
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.0
  * @module
  */
@@ -191,19 +191,19 @@ final class Analyzer {
      * of another table. If a cyclic dependency is detected, then this method returns
      * {@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  importedBy  if this table is imported by the foreigner keys of another table, the parent table.
-     *                     Otherwise {@code null}.
+     * @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  dependencyOf  if the analyzed table is imported/exported by foreigner keys,
+     *                       the table that "contains" this 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.
      */
-    public final Table table(final TableReference id, final GenericName name, final TableReference importedBy) throws Exception {
+    public final Table table(final TableReference id, final GenericName name, final TableReference dependencyOf) throws Exception {
         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(database, new TableAnalyzer(this, id, importedBy), null);
+            table = new Table(database, new TableAnalyzer(this, id, dependencyOf), null);
             if (tables.put(name, table) != null) {
                 // Should never happen. If thrown, we have a bug (e.g. synchronization) in this package.
                 throw new InternalDataStoreException(internalError());
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/FeatureAdapter.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/FeatureAdapter.java
index d0a3613..ac85330 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/FeatureAdapter.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/FeatureAdapter.java
@@ -49,7 +49,7 @@ import org.opengis.feature.FeatureType;
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.1
  * @module
  */
@@ -197,6 +197,7 @@ final class FeatureAdapter {
              */
             count = 0;                                                      // We will recount.
             for (final Relation dependency : table.importedKeys) {
+                if (dependency.excluded) continue;
                 if (dependency != noFollow) {
                     dependency.startFollowing(following);                   // Safety against never-ending recursivity.
                     associationNames   [count] = dependency.propertyName;
@@ -216,6 +217,7 @@ final class FeatureAdapter {
              * associations we need to iterate over all "Parks" rows referencing the city.
              */
             for (final Relation dependency : table.exportedKeys) {
+                if (dependency.excluded) continue;
                 dependency.startFollowing(following);                   // Safety against never-ending recursivity.
                 final Table foreigner  = dependency.getSearchTable();
                 final Relation inverse = foreigner.getInverseOf(dependency, table.name);
@@ -296,7 +298,7 @@ final class FeatureAdapter {
     private static int[] getColumnIndices(final SQLBuilder sql, final Relation dependency,
             final Map<String,Integer> columnIndices) throws InternalDataStoreException
     {
-        final Collection<String> columns = dependency.getForeignerKeys();
+        final Collection<String> columns = dependency.getOwnerColumns();
         int i = 0;
         final int[] indices = new int[columns.size()];
         for (final String column : columns) {
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/FeatureAnalyzer.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/FeatureAnalyzer.java
index e806e92..7afcc4a 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/FeatureAnalyzer.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/FeatureAnalyzer.java
@@ -61,7 +61,7 @@ import org.opengis.feature.FeatureType;
  *
  * @author  Alexis Manin (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.1
  * @module
  */
@@ -165,7 +165,7 @@ abstract class FeatureAnalyzer {
      * This is a helper method for {@link #getForeignerKeys(Relation.Direction)} implementations.
      */
     final void addForeignerKeys(Relation relation) {
-        for (final String column : relation.getForeignerKeys()) {
+        for (final String column : relation.getOwnerColumns()) {
             CollectionsExt.addToMultiValuesMap(foreignerKeys, column, relation);
             relation = null;       // Only the first column will be associated.
         }
@@ -244,7 +244,7 @@ abstract class FeatureAnalyzer {
         if (dependencies != null) {
             int count = 0;
             for (final Relation dependency : dependencies) {
-                if (dependency != null) {
+                if (dependency != null && !dependency.excluded) {
                     final GenericName typeName = dependency.getName(analyzer);
                     final Table table = analyzer.table(dependency, typeName, id);
                     /*
@@ -297,7 +297,7 @@ abstract class FeatureAnalyzer {
      * @throws DataStoreException if a logical error occurred while analyzing the relations.
      * @throws Exception for WKB parsing error or other kinds of errors.
      */
-    final PrimaryKey createAssociations(Relation[] exportedKeys) throws Exception {
+    final PrimaryKey createAssociations(final Relation[] exportedKeys) throws Exception {
         if (primaryKey.size() > 1) {
             if (!primaryKeyNullable) {
                 primaryKeyClass = Numbers.wrapperToPrimitive(primaryKeyClass);
@@ -307,7 +307,7 @@ abstract class FeatureAnalyzer {
         final PrimaryKey pk = PrimaryKey.create(primaryKeyClass, primaryKey);
         int count = 0;
         for (final Relation dependency : exportedKeys) {
-            if (dependency != null) {
+            if (dependency != null && !dependency.excluded) {
                 final GenericName typeName = dependency.getName(analyzer);
                 String propertyName = toHeuristicLabel(typeName.tip().toString());
                 final String base = propertyName;
@@ -315,7 +315,7 @@ abstract class FeatureAnalyzer {
                     propertyName = base + '-' + ++count;
                 }
                 dependency.propertyName = propertyName;
-                final Table table = analyzer.table(dependency, typeName, null);   // `null` because exported, not imported.
+                final Table table = analyzer.table(dependency, typeName, id);
                 final AssociationRoleBuilder association;
                 if (table != null) {
                     dependency.setSearchTable(analyzer, table, pk, Relation.Direction.EXPORT);
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/QueryAnalyzer.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/QueryAnalyzer.java
index 475a578..164acd4 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/QueryAnalyzer.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/QueryAnalyzer.java
@@ -124,7 +124,7 @@ final class QueryAnalyzer extends FeatureAnalyzer {
             {
                 if (reflect.next()) do {
                     final Relation relation = new Relation(analyzer, direction, reflect);
-                    if (columnNames.containsAll(relation.getForeignerKeys())) {
+                    if (columnNames.containsAll(relation.getOwnerColumns())) {
                         if (isImport) {
                             addForeignerKeys(relation);
                         }
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 4bfae80..af3fb28 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
@@ -53,7 +53,7 @@ import org.apache.sis.util.Debug;
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.0
  * @module
  */
@@ -170,6 +170,16 @@ final class Relation extends TableReference {
     private boolean useFullKey;
 
     /**
+     * Whether this relation should be omitted from the list of feature type properties.
+     * This is a temporary information used only at {@code FeatureType} construction time.
+     * A relation is excluded if table <var>A</var> is a dependency of table <var>B</var>
+     * (defined by foreigner keys) and the relation is pointing back to <var>A</var>.
+     *
+     * @since 1.2
+     */
+    boolean excluded;
+
+    /**
      * Creates a new relation for an imported key. The given {@code ResultSet} must be positioned
      * on the first row of {@code DatabaseMetaData.getImportedKeys​(catalog, schema, table)} result,
      * and the result must be sorted in the order of the given keys:
@@ -351,10 +361,10 @@ final class Relation extends TableReference {
     }
 
     /**
-     * Returns whether {@link #setSearchTable setSearchTable(…)} has been invoked.
+     * Returns whether {@link #setSearchTable setSearchTable(…)} needs to be invoked.
      */
-    final boolean isSearchTableDefined() {
-        return searchTable != null;
+    final boolean isSearchTableDeferred() {
+        return !excluded && searchTable == null;
     }
 
     /**
@@ -369,11 +379,12 @@ final class Relation extends TableReference {
 
     /**
      * Returns the foreigner key columns of the table that contains this relation.
-     * This method should be invoked only for {@link Direction#IMPORT} (otherwise the method name is wrong).
-     * This method returns only the foreigner key columns known to this relation; this is not necessarily
+     * For {@link Direction#IMPORT}, this is the foreigner keys in the enclosing table.
+     * For {@link Direction#EXPORT}, this is the primary keys in the dependency table.
+     * This method returns only the columns known to this relation; this is not necessarily
      * all the enclosing table foreigner keys. Some columns may be used in more than one relation.
      */
-    final Collection<String> getForeignerKeys() {
+    final Collection<String> getOwnerColumns() {
         return columns.values();
     }
 
@@ -440,6 +451,9 @@ final class Relation extends TableReference {
         if (freeText != null) {
             label = freeText + " ⟶ " + label;
         }
+        if (excluded) {
+            label += " (excluded)";
+        }
         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/SchemaModifier.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/SchemaModifier.java
index f648721..8a960ba 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/SchemaModifier.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/SchemaModifier.java
@@ -32,7 +32,7 @@ import org.opengis.feature.FeatureType;
  *       in which case the {@link TableReference} argument is no longer necessary.
  *
  * @author  Martin Desruisseaux (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.1
  * @module
  */
@@ -56,6 +56,18 @@ public interface SchemaModifier {
     }
 
     /**
+     * Returns {@code true} if the given dependency is allowed to have an association to its dependent feature.
+     * A value of {@code true} creates a cyclic dependency, which {@code SQLStore} can manage but may surprise users.
+     * The default value is {@code false}.
+     *
+     * @param  dependency  the dependency table.
+     * @return whether the dependency is allowed to have an association to its parent.
+     */
+    default boolean isCyclicAssociationAllowed(TableReference dependency) {
+        return false;
+    }
+
+    /**
      * The option for declaring a schema modifier at {@link org.apache.sis.storage.sql.SQLStore} creation time.
      *
      * @todo if we move this key in public API in the future, then it would be a
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 0d72ab8..a0f71a5 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
@@ -56,7 +56,7 @@ import org.opengis.feature.FeatureAssociationRole;
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.0
  * @module
  */
@@ -195,8 +195,8 @@ 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.
+     * 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.
      */
@@ -209,7 +209,7 @@ final class Table extends AbstractFeatureSet {
                 default: continue;
             }
             for (final Relation relation : relations) {
-                if (!relation.isSearchTableDefined()) {
+                if (relation.isSearchTableDeferred()) {
                     /*
                      * `ClassCastException` should never occur below because `relation.propertyName` fields
                      * have been set to association names. If `ClassCastException` occurs here, it is a bug
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/TableAnalyzer.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/TableAnalyzer.java
index 736bb44..9f76123 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/TableAnalyzer.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/TableAnalyzer.java
@@ -35,16 +35,16 @@ import org.apache.sis.internal.util.Strings;
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.1
  * @module
  */
 final class TableAnalyzer extends FeatureAnalyzer {
     /**
-     * If the analyzed table is imported by the foreigner keys of another table, the parent table.
-     * Otherwise {@code null}. This is relevant only for {@link Relation.Direction#EXPORT}.
+     * If the analyzed table is imported/exported by foreigner keys,
+     * the table that "contains" this table. Otherwise {@code null}.
      */
-    private final TableReference importedBy;
+    private final TableReference dependencyOf;
 
     /**
      * The table/schema name width {@code '_'} and {@code '%'} characters escaped.
@@ -57,16 +57,16 @@ final class TableAnalyzer extends FeatureAnalyzer {
      * 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  id          the catalog, schema and table name of the table to analyze.
-     * @param  importedBy  if the analyzed table is imported by the foreigner keys of another table,
-     *                     the parent table. Otherwise {@code null}.
+     * @param  id            the catalog, schema and table name of the table to analyze.
+     * @param  dependencyOf  if the analyzed table is imported/exported by foreigner keys,
+     *                       the table that "contains" this table. Otherwise {@code null}.
      * @throws SQLException if an error occurred while fetching information from the database.
      */
-    TableAnalyzer(final Analyzer analyzer, final TableReference id, final TableReference importedBy) throws SQLException {
+    TableAnalyzer(final Analyzer analyzer, final TableReference id, final TableReference dependencyOf) throws SQLException {
         super(analyzer, id);
-        this.importedBy = importedBy;
-        this.tableEsc   = escape(id.table);
-        this.schemaEsc  = escape(id.schema);
+        this.dependencyOf = dependencyOf;
+        this.tableEsc     = escape(id.table);
+        this.schemaEsc    = escape(id.schema);
         try (ResultSet reflect = analyzer.metadata.getPrimaryKeys(id.catalog, id.schema, id.table)) {
             while (reflect.next()) {
                 primaryKey.add(analyzer.getUniqueString(reflect, Reflection.COLUMN_NAME));
@@ -115,9 +115,20 @@ final class TableAnalyzer extends FeatureAnalyzer {
         {
             if (reflect.next()) do {
                 final Relation relation = new Relation(analyzer, direction, reflect);
+                if (relation.equals(dependencyOf)) {
+                    if (!isImport) {
+                        /*
+                         * For export keys we can just forget the relation because there is no column in the
+                         * analyzed table for the relation (i.e. the foreigner keys are not in this table).
+                         */
+                        continue;
+                    }
+                    final SchemaModifier customizer = analyzer.customizer;
+                    relation.excluded = (customizer == null) || !customizer.isCyclicAssociationAllowed(id);
+                }
                 if (isImport) {
                     addForeignerKeys(relation);
-                } else if (relation.equals(importedBy)) {
+                } else if (relation.excluded) {
                     continue;
                 }
                 relations.add(relation);
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/package-info.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/package-info.java
index cb0c948..145b81c 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/package-info.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/package-info.java
@@ -31,7 +31,7 @@
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.0
  * @module
  */
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/storage/sql/package-info.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/storage/sql/package-info.java
index 85e98ec..5fab066 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/storage/sql/package-info.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/storage/sql/package-info.java
@@ -56,7 +56,7 @@
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.0
  * @module
  */
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 3dcf87b..baf9f19 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
@@ -24,11 +24,13 @@ import java.util.Collection;
 import java.util.stream.Stream;
 import java.util.concurrent.atomic.AtomicInteger;
 import org.apache.sis.storage.FeatureSet;
+import org.apache.sis.storage.FeatureQuery;
 import org.apache.sis.storage.StorageConnector;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.filter.DefaultFilterFactory;
 import org.apache.sis.internal.feature.AttributeConvention;
-import org.apache.sis.storage.FeatureQuery;
+import org.apache.sis.internal.sql.feature.SchemaModifier;
+import org.apache.sis.internal.sql.feature.TableReference;
 import org.apache.sis.test.sql.TestDatabase;
 import org.apache.sis.test.TestUtilities;
 import org.apache.sis.test.TestCase;
@@ -51,7 +53,7 @@ import org.opengis.filter.SortOrder;
  *
  * @author  Martin Desruisseaux (Geomatys)
  * @author  Alexis Manin (Geomatys)
- * @version 1.1
+ * @version 1.2
  * @since   1.1
  * @module
  */
@@ -87,6 +89,13 @@ public final strictfp class SQLStoreTest extends TestCase {
     };
 
     /**
+     * Whether dependencies are allowed to have an association to their dependent feature.
+     *
+     * @see SchemaModifier#isCyclicAssociationAllowed(TableReference)
+     */
+    private boolean isCyclicAssociationAllowed;
+
+    /**
      * The {@code Country} value for Canada, or {@code null} if not yet visited.
      * This feature should appear twice, and all those occurrences should use the exact same instance.
      * We use that for verifying the {@code Table.instanceForPrimaryKeys} caching.
@@ -143,7 +152,9 @@ public final strictfp class SQLStoreTest extends TestCase {
      *                   and will be the only schema to exist (ignoring system schema); i.e. we assume that there
      *                   is no ambiguity if we do not specify the schema in {@link SQLStore} constructor.
      */
-    private void test(final TestDatabase database, final boolean inMemory) throws Exception {
+    private void test(final TestDatabase database, final boolean inMemory)
+            throws Exception
+    {
         final String[] scripts = {
             "CREATE SCHEMA " + SCHEMA + ';',
             "file:Features.sql"
@@ -154,42 +165,8 @@ public final strictfp class SQLStoreTest extends TestCase {
         try (TestDatabase tmp = database) {                 // TODO: omit `tmp` with JDK16.
             tmp.executeSQL(SQLStoreTest.class, scripts);
             final StorageConnector connector = new StorageConnector(tmp.source);
-            try (SQLStore store = new SQLStore(new SQLStoreProvider(), connector,
-                    ResourceDefinition.table(null, inMemory ? null : SCHEMA, "Cities")))
-            {
-                final FeatureSet cities = store.findResource("Cities");
-                /*
-                 * Feature properties should be in same order than columns in the database table, except for
-                 * the generated identifier. Note that the country is an association to another feature.
-                 */
-                verifyFeatureType(cities.getType(),
-                        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(store.findResource("Countries").getType(),
-                        new String[] {"sis:identifier", "code",       "native_name"},
-                        new Object[] {null,             String.class, String.class});
-
-                verifyFeatureType(store.findResource("Parks").getType(),
-                        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});
-
-                final Map<String,Integer> countryCount = new HashMap<>();
-                try (Stream<Feature> features = cities.features(false)) {
-                    features.forEach((f) -> verifyContent(f, countryCount, true));
-                }
-                assertEquals(Integer.valueOf(2), countryCount.remove("CAN"));
-                assertEquals(Integer.valueOf(1), countryCount.remove("FRA"));
-                assertEquals(Integer.valueOf(1), countryCount.remove("JPN"));
-                assertTrue(countryCount.isEmpty());
-                /*
-                 * Verify overloaded stream operations (sorting, etc.).
-                 */
-                verifySimpleQuerySorting(store);
-                verifySimpleWhere(store);
-                verifyStreamOperations(store.findResource("Cities"));
-                canada = null;
-            }
+            final ResourceDefinition table = ResourceDefinition.table(null, inMemory ? null : SCHEMA, "Cities");
+            testTableQuery(connector, table);
             /*
              * Verify using SQL statements instead of tables.
              */
@@ -197,7 +174,73 @@ public final strictfp class SQLStoreTest extends TestCase {
             verifyNestedSQLQuery(connector);
             verifyLimitOffsetAndColumnSelectionFromQuery(connector);
             verifyDistinctQuery(connector);
+            /*
+             * Test on the table again, but with cyclic associations enabled.
+             */
+            connector.setOption(SchemaModifier.OPTION, new SchemaModifier() {
+                @Override public boolean isCyclicAssociationAllowed(TableReference dependency) {
+                    return true;
+                }
+            });
+            isCyclicAssociationAllowed = true;
+            testTableQuery(connector, table);
+        }
+    }
+
+    /**
+     * Creates a {@link SQLStore} instance with the specified table as a resource, then tests some queries.
+     */
+    private void testTableQuery(final StorageConnector connector, final ResourceDefinition table) throws Exception {
+        try (SQLStore store = new SQLStore(new SQLStoreProvider(), connector, table)) {
+            verifyFeatureTypes(store);
+            final Map<String,Integer> countryCount = new HashMap<>();
+            try (Stream<Feature> features = store.findResource("Cities").features(false)) {
+                features.forEach((f) -> verifyContent(f, countryCount));
+            }
+            assertEquals(Integer.valueOf(2), countryCount.remove("CAN"));
+            assertEquals(Integer.valueOf(1), countryCount.remove("FRA"));
+            assertEquals(Integer.valueOf(1), countryCount.remove("JPN"));
+            assertTrue(countryCount.isEmpty());
+            /*
+             * Verify overloaded stream operations (sorting, etc.).
+             */
+            verifySimpleQuerySorting(store);
+            verifySimpleWhere(store);
+            verifyStreamOperations(store.findResource("Cities"));
+        }
+        canada = null;
+    }
+
+    /**
+     * Verifies the feature types of the "Cities" resource and its dependencies.
+     * Feature properties should be in same order than columns in the database table, except for
+     * the generated identifier. Note that the country is an association to another feature.
+     *
+     * @param  isCyclicAssociationAllowed  whether dependencies are allowed to have an association
+     *         to their dependent feature, which create a cyclic dependency.
+     */
+    private void verifyFeatureTypes(final SQLStore store) throws DataStoreException {
+        verifyFeatureType(store.findResource("Cities").getType(),
+                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(store.findResource("Countries").getType(),
+                new String[] {"sis:identifier", "code",       "native_name"},
+                new Object[] {null,             String.class, String.class});
+        /*
+         * If cyclic dependencies are allowed, an additional properties "FK_City" is present
+         * compared to the case where cyclic dependencies are avoided.
+         */
+        final String[] expectedNames;
+        final Object[] expectedTypes;
+        if (isCyclicAssociationAllowed) {
+            expectedNames = new String[] {"sis:identifier", "pk:country", "FK_City", "city",       "native_name", "english_name"};
+            expectedTypes = new Object[] {null,             String.class, "Cities",  String.class, String.class,  String.class};
+        } else {
+            expectedNames = new String[] {"sis:identifier", "country",    "city",       "native_name", "english_name"};
+            expectedTypes = new Object[] {null,             String.class, String.class, String.class,  String.class};
         }
+        verifyFeatureType(store.findResource("Parks").getType(), expectedNames, expectedTypes);
     }
 
     /**
@@ -234,9 +277,8 @@ public final strictfp class SQLStoreTest extends TestCase {
      *
      * @param  feature       a feature returned by the stream.
      * @param  countryCount  number of time that the each country has been seen while iterating over the cities.
-     * @param  isTable       {@code true} if the resource is from a table, or {@code false} if from a query.
      */
-    private void verifyContent(final Feature feature, final Map<String,Integer> countryCount, final boolean isTable) {
+    private void verifyContent(final Feature feature, final Map<String,Integer> countryCount) {
         final String city = feature.getPropertyValue("native_name").toString();
         final City c;
         boolean isCanada = false;
@@ -288,7 +330,7 @@ public final strictfp class SQLStoreTest extends TestCase {
              * Verify the reverse association form Parks to Cities.
              * This create a cyclic graph, but SQLStore is capable to handle it.
              */
-            if (isTable) {
+            if (isCyclicAssociationAllowed) {
                 assertSame("City → Park → City", feature, pf.getPropertyValue("FK_City"));
             }
         }
@@ -420,13 +462,14 @@ public final strictfp class SQLStoreTest extends TestCase {
             final FeatureSet cities = store.findResource("LargeCities");
             final Map<String,Integer> countryCount = new HashMap<>();
             try (Stream<Feature> features = cities.features(false)) {
-                features.forEach((f) -> verifyContent(f, countryCount, false));
+                features.forEach((f) -> verifyContent(f, countryCount));
             }
             assertEquals(Integer.valueOf(1), countryCount.remove("CAN"));
             assertEquals(Integer.valueOf(1), countryCount.remove("FRA"));
             assertEquals(Integer.valueOf(1), countryCount.remove("JPN"));
             assertTrue(countryCount.isEmpty());
         }
+        canada = null;
     }
 
     /**