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: First draft of a SQLStore capable to return the actual feature instances.
Date Mon, 16 Jul 2018 16:54:00 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 6836099  First draft of a SQLStore capable to return the actual feature instances.
6836099 is described below

commit 6836099d96477881a1272c5be8c7eb39cfb97a3a
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Mon Jul 16 18:53:33 2018 +0200

    First draft of a SQLStore capable to return the actual feature instances.
---
 .../sis/internal/metadata/sql/SQLBuilder.java      |   9 +
 .../apache/sis/internal/sql/feature/Analyzer.java  |  41 ++-
 .../apache/sis/internal/sql/feature/Database.java  |  23 +-
 .../apache/sis/internal/sql/feature/Features.java  | 280 +++++++++++++++++++++
 .../apache/sis/internal/sql/feature/Relation.java  |  68 +++--
 .../apache/sis/internal/sql/feature/Resources.java |   5 +
 .../sis/internal/sql/feature/Resources.properties  |   1 +
 .../internal/sql/feature/Resources_fr.properties   |   1 +
 .../org/apache/sis/internal/sql/feature/Table.java | 177 +++++++++----
 .../java/org/apache/sis/storage/sql/SQLStore.java  |   8 +-
 .../org/apache/sis/storage/sql/SQLStoreTest.java   | 117 +++++++--
 11 files changed, 615 insertions(+), 115 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..37e807c 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
@@ -91,6 +91,15 @@ public class SQLBuilder {
     }
 
     /**
+     * Returns the current length of the SQL statement.
+     *
+     * @return the current string length of SQL statement.
+     */
+    public final int length() {
+        return buffer.length();
+    }
+
+    /**
      * Returns {@code true} if the builder is currently empty.
      *
      * @return {@code true} if the builder is empty.
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 b4ed102..ecce4bc 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
@@ -22,13 +22,14 @@ import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashSet;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Locale;
 import java.util.Objects;
-import java.sql.SQLException;
-import java.sql.DatabaseMetaData;
-import java.util.Collections;
 import java.util.logging.Level;
 import java.util.logging.LogRecord;
+import javax.sql.DataSource;
+import java.sql.SQLException;
+import java.sql.DatabaseMetaData;
 import org.opengis.util.NameSpace;
 import org.opengis.util.NameFactory;
 import org.opengis.util.GenericName;
@@ -39,7 +40,6 @@ import org.apache.sis.storage.DataStore;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.util.logging.WarningListeners;
 import org.apache.sis.util.resources.ResourceInternationalString;
-import org.apache.sis.util.resources.Errors;
 
 
 /**
@@ -55,6 +55,13 @@ import org.apache.sis.util.resources.Errors;
  */
 final class Analyzer {
     /**
+     * Provider of (pooled) connections to the database. This is the main argument provided by users
+     * when creating a {@link org.apache.sis.storage.sql.SQLStore}. This data source should be pooled,
+     * because {@code SQLStore} will frequently opens and closes connections.
+     */
+    final DataSource source;
+
+    /**
      * Information about the database as a whole.
      * Used for fetching tables, columns, primary keys <i>etc.</i>
      */
@@ -117,10 +124,16 @@ final class Analyzer {
 
     /**
      * Creates a new analyzer for the database described by given metadata.
+     *
+     * @param  source     the data source, usually given by user at {@code SQLStore} creation time.
+     * @param  metadata   Value of {@code source.getConnection().getMetaData()}.
+     * @param  listeners  Value of {@code SQLStore.listeners}.
+     * @param  locale     Value of {@code SQLStore.getLocale()}.
      */
-    Analyzer(final DatabaseMetaData metadata, final WarningListeners<DataStore> listeners, final Locale locale)
-            throws SQLException
+    Analyzer(final DataSource source, final DatabaseMetaData metadata, final WarningListeners<DataStore> listeners,
+             final Locale locale) throws SQLException
     {
+        this.source      = source;
         this.metadata    = metadata;
         this.listeners   = listeners;
         this.locale      = locale;
@@ -225,20 +238,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  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.
      * @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) throws SQLException, DataStoreException {
+    final Table table(final TableReference id, final GenericName name, final boolean isDependency)
+            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);
+            table = new Table(this, id, isDependency);
             if (tables.put(name, table) != null) {
                 // Should never happen. If thrown, we have a bug (e.g. synchronization) in this package.
-                throw new DataStoreException(Errors.format(Errors.Keys.UnexpectedChange_1, name));
+                throw new DataStoreException(Resources.format(Resources.Keys.InternalError));
             }
         }
         return table;
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 cf03790..026a5e2 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
@@ -25,6 +25,7 @@ import java.sql.Connection;
 import java.sql.DatabaseMetaData;
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import javax.sql.DataSource;
 import org.opengis.util.GenericName;
 import org.apache.sis.internal.metadata.sql.Reflection;
 import org.apache.sis.internal.storage.MetadataBuilder;
@@ -40,8 +41,6 @@ import org.apache.sis.util.logging.WarningListeners;
 import org.apache.sis.util.collection.TreeTable;
 import org.apache.sis.util.Debug;
 
-// Branch-dependent imports
-
 
 /**
  * Represent the structure of features in the database.
@@ -102,16 +101,18 @@ public final class Database {
      * </ul>
      *
      * @param  store        the data store for which we are creating a model. Used only in case of error.
-     * @param  connection   connection to the database.
-     * @param  tableNames   qualified name of the tables.
-     * @param  listeners    where to send the warnings.
+     * @param  connection   connection to the database. Sometime the caller already has a connection at hand.
+     * @param  source       provider of (pooled) connections to the database. Specified by users at construction time.
+     * @param  tableNames   qualified name of the tables. Specified by users at construction time.
+     * @param  listeners    where to send the warnings. This is the value of {@code store.listeners}.
      * @throws SQLException if a database error occurred while reading metadata.
      * @throws DataStoreException if a logical error occurred while analyzing the database structure.
      */
-    public Database(final SQLStore store, final Connection connection, final GenericName[] tableNames,
-            final WarningListeners<DataStore> listeners) throws SQLException, DataStoreException
+    public Database(final SQLStore store, final Connection connection, final DataSource source,
+            final GenericName[] tableNames, final WarningListeners<DataStore> listeners)
+            throws SQLException, DataStoreException
     {
-        final Analyzer analyzer = new Analyzer(connection.getMetaData(), listeners, store.getLocale());
+        final Analyzer analyzer = new Analyzer(source, connection.getMetaData(), listeners, store.getLocale());
         final String[] tableTypes = getTableTypes(analyzer.metadata);
         final Set<TableReference> declared = new LinkedHashSet<>();
         for (final GenericName tableName : tableNames) {
@@ -138,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)));
+            tableList.add(analyzer.table(reference, reference.getName(analyzer), false));
         }
         /*
          * At this point we finished to create the table explicitly requested by the users.
@@ -148,7 +149,7 @@ public final class Database {
         boolean hasGeometry = false;
         tablesByNames = new FeatureNaming<>();
         for (final Table table : analyzer.finish()) {
-            tablesByNames.add(store, table.getType().getName(), table);
+            tablesByNames.add(store, table.featureType.getName(), table);
             hasGeometry |= table.hasGeometry;
         }
         this.tables = tableList.toArray(new Table[tableList.size()]);
@@ -183,7 +184,7 @@ public final class Database {
     public final void listTables(final DatabaseMetaData metadata, final MetadataBuilder builder) throws SQLException {
         for (final Table table : tables) {
             final long n = table.countRows(metadata, false);
-            builder.addFeatureType(table.getType(), (n > 0 && n <= Integer.MAX_VALUE) ? (int) n : null);
+            builder.addFeatureType(table.featureType, (n > 0 && n <= Integer.MAX_VALUE) ? (int) n : null);
         }
     }
 
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
new file mode 100644
index 0000000..1358bc4
--- /dev/null
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Features.java
@@ -0,0 +1,280 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.sis.internal.sql.feature;
+
+import java.util.List;
+import java.util.ArrayList;
+import java.util.Spliterator;
+import java.util.function.Consumer;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.Statement;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import org.apache.sis.internal.metadata.sql.SQLBuilder;
+import org.apache.sis.util.collection.BackingStoreException;
+
+// Branch-dependent imports
+import org.opengis.feature.Feature;
+import org.opengis.feature.FeatureType;
+
+
+/**
+ * Iterator over feature instances.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+final class Features implements Spliterator<Feature>, Runnable {
+    /**
+     * The type of features to create.
+     */
+    private final FeatureType featureType;
+
+    /**
+     * Name of attributes in feature instances, excluding operations and associations to other tables.
+     * Those names are in the order of columns declared in the {@code SELECT <columns} statement.
+     * This array is a shared instance and shall not be modified.
+     */
+    private final String[] attributeNames;
+
+    /**
+     * Name of the properties where are stored associations in feature instances.
+     */
+    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]}.
+     */
+    private final Features[] importedFeatures;
+
+    /**
+     * 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.
+     */
+    private final int[] foreignerKeyIndices;
+
+    /**
+     * If this iterator returns only the feature matching some condition (typically a primary key value),
+     * the statement for performing that filtering. Otherwise if this iterator returns all features, then
+     * this field is {@code null}.
+     */
+    private final PreparedStatement statement;
+
+    /**
+     * The result of executing the SQL query for a {@link Table}.
+     */
+    private ResultSet result;
+
+    /**
+     * Estimated number of rows, or {@literal <= 0} if unknown.
+     */
+    private final long estimatedSize;
+
+    /**
+     * Creates a new iterator over the feature instances.
+     */
+    Features(final Table table, final Connection connection, final String[] attributeNames, final String[] attributeColumns,
+             final Relation[] importedKeys, final Relation componentOf) throws SQLException
+    {
+        this.featureType = table.featureType;
+        this.attributeNames = attributeNames;
+        final DatabaseMetaData metadata = connection.getMetaData();
+        estimatedSize = (componentOf == null) ? table.countRows(metadata, true) : 0;
+        final SQLBuilder sql = new SQLBuilder(metadata, true).append("SELECT");
+        /*
+         * 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++;
+        }
+        /*
+         * 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.
+         */
+        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.getRelatedTable().features(connection, dependency);
+                for (final String column : dependency.getForeignerKeys()) {
+                    if (count != 0) sql.append(',');
+                    sql.append(' ').append(column);
+                    count++;
+                }
+                foreignerKeyIndices[++i] = count;
+            }
+        } else {
+            associationNames    = null;
+            importedFeatures    = null;
+            foreignerKeyIndices = null;
+        }
+        /*
+         * Create a Statement if we don't need any condition, or a PreparedStatement
+         * if we need to add a "WHERE" clause.
+         */
+        sql.append(" FROM ").appendIdentifier(table.schema, table.table);
+        if (componentOf == null) {
+            statement = null;
+            result = connection.createStatement().executeQuery(sql.toString());
+        } else {
+            String separator = " WHERE ";
+            for (String primaryKey : componentOf.getPrimaryKeys()) {
+                sql.append(separator).append(primaryKey).append("=?");
+                separator = " AND ";
+            }
+            statement = connection.prepareStatement(sql.toString());
+        }
+    }
+
+    /**
+     * Declares that this iterator never returns {@code null} elements.
+     */
+    @Override
+    public int characteristics() {
+        return NONNULL;
+    }
+
+    /**
+     * Returns the estimated number of features, or {@link Long#MAX_VALUE} if unknown.
+     */
+    @Override
+    public long estimateSize() {
+        return (estimatedSize > 0) ? estimatedSize : Long.MAX_VALUE;
+    }
+
+    /**
+     * Current version does not support split.
+     *
+     * @return always {@code null}.
+     */
+    @Override
+    public Spliterator<Feature> trySplit() {
+        return null;
+    }
+
+    /**
+     * Gives the next feature to the given consumer.
+     */
+    @Override
+    public boolean tryAdvance(final Consumer<? super Feature> action) {
+        try {
+            return fetch(action, false);
+        } catch (SQLException e) {
+            throw new BackingStoreException(e);
+        }
+    }
+
+    /**
+     * Gives all remaining features to the given consumer.
+     */
+    @Override
+    public void forEachRemaining(final Consumer<? super Feature> action) {
+        try {
+            fetch(action, true);
+        } catch (SQLException e) {
+            throw new BackingStoreException(e);
+        }
+    }
+
+    /**
+     * Gives at least the next feature to the given consumer.
+     * Gives all remaining features if {@code all} is {@code true}.
+     */
+    private boolean fetch(final Consumer<? super Feature> action, final boolean all) throws SQLException {
+        while (result.next()) {
+            final Feature feature = featureType.newInstance();
+            for (int i=0; i < attributeNames.length; i++) {
+                final Object value = result.getObject(i+1);
+                if (!result.wasNull()) {
+                    feature.setPropertyValue(attributeNames[i], value);
+                }
+            }
+            if (importedFeatures != null) {
+                for (int i=0; i < importedFeatures.length; i++) {
+                    final Features dependency = importedFeatures[i];
+                    final int last = foreignerKeyIndices[i+1];
+                    for (int p=1, c = foreignerKeyIndices[i]; ++c <= last; p++) {
+                        dependency.statement.setObject(p, result.getObject(c));
+                    }
+                    final Object value = dependency.fetchReferenced();
+                    feature.setPropertyValue(associationNames[i], value);
+                }
+            }
+            action.accept(feature);
+            if (!all) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Executes the current {@link #statement} and stores all features in a list.
+     * Returns {@code null} if there is no feature, the feature instance if there
+     * is only one, and a list of features otherwise.
+     */
+    private Object fetchReferenced() throws SQLException {
+        final List<Feature> instances = new ArrayList<>();
+        try (ResultSet r = statement.executeQuery()) {
+            result = r;
+            fetch(instances::add, true);
+        }
+        switch (instances.size()) {
+            case 0:  return null;
+            case 1:  return instances.get(0);
+            default: return instances;
+        }
+    }
+
+    /**
+     * Closes the (pooled) connection.
+     */
+    @Override
+    public void run() {
+        try {
+            final Statement s = result.getStatement();
+            try (Connection c = s.getConnection()) {
+                result.close();
+                s.close();
+                for (final Features dependency : importedFeatures) {
+                    dependency.statement.close();
+                }
+                // No need to close this.statement because it is null.
+            }
+        } catch (SQLException e) {
+            throw new BackingStoreException(e);
+        }
+    }
+}
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 d257b6f..8bed2c1 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
@@ -16,9 +16,9 @@
  */
 package org.apache.sis.internal.sql.feature;
 
-import java.util.List;
 import java.util.Map;
 import java.util.LinkedHashMap;
+import java.util.Collection;
 import java.util.Objects;
 import java.sql.ResultSet;
 import java.sql.SQLException;
@@ -26,6 +26,7 @@ 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.util.collection.TreeTable;
 import org.apache.sis.util.Debug;
 
@@ -122,6 +123,17 @@ final class Relation extends TableReference {
     final boolean cascadeOnDelete;
 
     /**
+     * The other table which is related to the table containing this relation.
+     * This is set during {@link Table} construction and should not be modified after that point.
+     */
+    private Table related;
+
+    /**
+     * The name of the feature property where the association to {@link #related} table will be stored.
+     */
+    private String propertyName;
+
+    /**
      * 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:
@@ -168,24 +180,46 @@ final class Relation extends TableReference {
     }
 
     /**
-     * Adds to the given map the foreigner keys of the table that contains this relation.
-     * This method adds only the foreigner keys known to this relation; this is not necessarily
-     * all the table foreigner keys. Some columns may be used in more than one relation.
-     *
-     * <p>This method puts {@code this} relation in the values of the map. However if this relation describes a
-     * foreigner key using more than one column, then only one of the column will be associated to {@code this}
-     * and all other columns will be associated to {@code null}. For example if a foreigner key uses 3 columns,
-     * then we want to replace only one of those columns by an association, not create 3 identical associations.</p>
-     *
-     * @param  addTo  the map where to add the foreigner keys. After this method returns, the set of map keys
-     *                will contain the column names and exactly one of the values will be this relation.
+     * Invoked after construction for setting the target table.
+     * Shall be invoked exactly once.
      */
-    final void getForeignerKeys(final Map<String, List<Relation>> addTo) {
-        Relation rel = this;
-        for (final String column : columns.values()) {
-            CollectionsExt.addToMultiValuesMap(addTo, column, rel);
-            rel = null;     // Only the first column will be associated.
+    final void setRelatedTable(final Table table, final String property) throws DataStoreException {
+        if (related != null) {
+            throw new DataStoreException(Resources.format(Resources.Keys.InternalError));     // Should never happen.
         }
+        related = table;
+        propertyName = property;
+    }
+
+    /**
+     * Returns the other table which is related to the table containing this relation.
+     * See {@link Direction} for more details about the relation.
+     */
+    final Table getRelatedTable() {
+        return related;
+    }
+
+    /**
+     * Returns the name of the feature property where the association to {@link #related} table will be stored.
+     */
+    final String getPropertyName() {
+        return propertyName;
+    }
+
+    /**
+     * Returns the primary keys of the table related by the foreigner keys.
+     */
+    final Collection<String> getPrimaryKeys() {
+        return columns.keySet();
+    }
+
+    /**
+     * Returns the foreigner keys of the table that contains this relation. This method returns only
+     * the foreigner keys known to this relation; this is not necessarily all the table foreigner keys.
+     * Some columns may be used in more than one relation.
+     */
+    final Collection<String> getForeignerKeys() {
+        return columns.values();
     }
 
     /**
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources.java
index c5b980f..e6d6358 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources.java
@@ -76,6 +76,11 @@ public final class Resources extends IndexedResourceBundle {
         public static final short IllegalQualifiedName_1 = 3;
 
         /**
+         * Unexpected error while analyzing the database schema.
+         */
+        public static final short InternalError = 6;
+
+        /**
          * Table names, optionally with their schemas and catalogs.
          */
         public static final short QualifiedTableNames = 2;
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources.properties b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources.properties
index 4ca1b07..ff78d5a 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources.properties
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources.properties
@@ -22,5 +22,6 @@
 DataSource                        = Provider of connections to the database.
 DuplicatedEntity_2                = Unexpected duplication of \u201c{0}\u201d entity named \u201c{1}\u201d.
 IllegalQualifiedName_1            = \u201c{0}\u201d is not a valid qualified name for a table.
+InternalError                     = Unexpected error while analyzing the database schema.
 QualifiedTableNames               = Table names, optionally with their schemas and catalogs.
 UnknownType_1                     = No mapping from SQL type \u201c{0}\u201d to a Java class.
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources_fr.properties b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources_fr.properties
index d0f55bd..a9e2f83 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources_fr.properties
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/internal/sql/feature/Resources_fr.properties
@@ -27,5 +27,6 @@
 DataSource                        = Fournisseur de connexions \u00e0 la base de donn\u00e9es.
 DuplicatedEntity_2                = Doublon inattendu d\u2019une entit\u00e9 \u00ab\u202f{0}\u202f\u00bb nomm\u00e9e \u00ab\u202f{1}\u202f\u00bb.
 IllegalQualifiedName_1            = \u00ab\u202f{0}\u202f\u00bb n\u2019est pas un nom qualifi\u00e9 de table valide.
+InternalError                     = Erreur inattendue pendant l\u2019analyse du sch\u00e9ma de la base de donn\u00e9es.
 QualifiedTableNames               = Noms de tables, optionnellemment avec leurs noms de sch\u00e9mas et catalogues.
 UnknownType_1                     = Pas de correspondance entre le type SQL \u00ab\u202f{0}\u202f\u00bb et une classe Java.
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 b147315..b150d6a 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
@@ -18,13 +18,18 @@ package org.apache.sis.internal.sql.feature;
 
 import java.util.Map;
 import java.util.HashMap;
+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;
+import java.sql.Connection;
 import java.sql.ResultSet;
 import java.sql.SQLException;
+import javax.sql.DataSource;
 import org.opengis.util.GenericName;
 import org.opengis.referencing.crs.CoordinateReferenceSystem;
 import org.apache.sis.feature.builder.AttributeRole;
@@ -40,12 +45,12 @@ import org.apache.sis.internal.util.CollectionsExt;
 import org.apache.sis.storage.DataStoreException;
 import org.apache.sis.util.collection.TreeTable;
 import org.apache.sis.util.CharSequences;
+import org.apache.sis.util.Exceptions;
 import org.apache.sis.util.Debug;
 
 // Branch-dependent imports
 import org.opengis.feature.Feature;
 import org.opengis.feature.FeatureType;
-import org.opengis.feature.PropertyType;
 import org.opengis.feature.AttributeType;
 import org.opengis.feature.FeatureAssociationRole;
 
@@ -64,29 +69,47 @@ import org.opengis.feature.FeatureAssociationRole;
  */
 final class Table extends AbstractFeatureSet {
     /**
+     * Provider of (pooled) connections to the database.
+     */
+    private final DataSource source;
+
+    /**
      * The structure of this table represented as a feature. Each feature attribute is a table column,
      * except synthetic attributes like "sis:identifier". The feature may also contain associations
      * inferred from foreigner keys that are not immediately apparent in the table.
      */
-    private final FeatureType featureType;
+    final FeatureType featureType;
 
     /**
-     * The primary key of this table. The boolean values tells whether the column
-     * uses auto-increment, with null value meaning that we don't know.
+     * The name in the database of this {@code Table} object, together with its schema.
      */
-    private final Map<String,Boolean> primaryKeys;
+    final String schema, table;
+
+    /**
+     * Name of attributes in feature instances, excluding operations and associations to other tables.
+     * Those names are in the order of columns declared in the {@code SELECT <columns} statement.
+     * This array shall not be modified after construction.
+     */
+    private final String[] attributeNames;
+
+    /**
+     * Name of columns corresponding to each {@link #attributeNames}. This is often a reference to the
+     * same array than {@link #attributeNames}, but may be different if some attributes have been renamed
+     * for avoiding name collisions.
+     */
+    private final String[] attributeColumns;
 
     /**
      * The primary keys of other tables that are referenced by this table foreign key columns.
-     * They are 0:1 relations.
+     * They are 0:1 relations. May be {@code null} if there is no imported keys.
      */
-    private final List<Relation> importedKeys;
+    private final Relation[] importedKeys;
 
     /**
      * The foreign keys of other tables that reference this table primary key columns.
-     * They are 0:N relations
+     * They are 0:N relations. May be {@code null} if there is no exported keys.
      */
-    private final List<Relation> exportedKeys;
+    private final Relation[] exportedKeys;
 
     /**
      * {@code true} if this table contains at least one geometry column.
@@ -100,11 +123,18 @@ final class Table extends AbstractFeatureSet {
      *
      * @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.
      */
-    Table(final Analyzer analyzer, final TableReference id) throws SQLException, DataStoreException {
+    Table(final Analyzer analyzer, final TableReference id, final boolean isDependency)
+            throws SQLException, DataStoreException
+    {
         super(analyzer.listeners);
-        final String tableEsc  = analyzer.escape(id.table);
-        final String schemaEsc = analyzer.escape(id.schema);
+        this.source = analyzer.source;
+        this.schema = id.schema;
+        this.table  = id.table;
+        final String tableEsc  = analyzer.escape(table);
+        final String schemaEsc = analyzer.escape(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.
@@ -116,7 +146,7 @@ final class Table extends AbstractFeatureSet {
          * We don't do that for now because of uncertainties (which index to use if there is
          * many? If they are suitable as identifiers why they are not primary keys?).
          */
-        final Map<String,Boolean> primaryKeys = new HashMap<>();
+        final Map<String,Boolean> primaryKeys = new LinkedHashMap<>();
         try (ResultSet reflect = analyzer.metadata.getPrimaryKeys(id.catalog, id.schema, id.table)) {
             while (reflect.next()) {
                 primaryKeys.put(reflect.getString(Reflection.COLUMN_NAME), null);
@@ -135,27 +165,38 @@ final class Table extends AbstractFeatureSet {
          * multi-occurrences.
          */
         final List<Relation> importedKeys = new ArrayList<>();
-        final List<Relation> exportedKeys = new ArrayList<>();
         final Map<String, List<Relation>> foreignerKeys = new HashMap<>();
         try (ResultSet reflect = analyzer.metadata.getImportedKeys(id.catalog, id.schema, id.table)) {
             if (reflect.next()) do {
-                final Relation relation = new Relation(Relation.Direction.IMPORT, reflect);
-                relation.getForeignerKeys(foreignerKeys);
+                Relation relation = new Relation(Relation.Direction.IMPORT, reflect);
                 importedKeys.add(relation);
+                for (final String column : relation.getForeignerKeys()) {
+                    CollectionsExt.addToMultiValuesMap(foreignerKeys, column, relation);
+                    relation = null;     // Only the first column will be associated.
+                }
             } while (!reflect.isClosed());
         }
-        try (ResultSet reflect = analyzer.metadata.getExportedKeys(id.catalog, id.schema, id.table)) {
-            if (reflect.next()) do {
-                exportedKeys.add(new Relation(Relation.Direction.EXPORT, reflect));
-            } 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(Relation.Direction.EXPORT, reflect));
+                } while (!reflect.isClosed());
+            }
         }
         /*
          * For each column in the table that is not a foreigner key, create an AttributeType of the same name.
          * The Java type is inferred from the SQL type, and the attribute cardinality in inferred from the SQL
-         * nullability.
+         * nullability. Attribute names are added in the 'attributeNames' and 'attributeColumns' list. Those
+         * names are usually the same, except when a column is used both as a primary key and as foreigner key.
          */
         boolean hasGeometry = false;
         int startWithLowerCase = 0;
+        final List<String> attributeNames = new ArrayList<>();
+        final List<String> attributeColumns = new ArrayList<>();
         final FeatureTypeBuilder feature = new FeatureTypeBuilder(analyzer.nameFactory, analyzer.functions.library, analyzer.locale);
         try (ResultSet reflect = analyzer.metadata.getColumns(id.catalog, schemaEsc, tableEsc, null)) {
             while (reflect.next()) {
@@ -182,6 +223,8 @@ final class Table extends AbstractFeatureSet {
                  */
                 AttributeTypeBuilder<?> attribute = null;
                 if (isPrimaryKey || dependencies == null) {
+                    attributeNames.add(column);
+                    attributeColumns.add(column);
                     final String typeName = reflect.getString(Reflection.TYPE_NAME);
                     Class<?> type = analyzer.functions.toJavaType(reflect.getInt(Reflection.DATA_TYPE), typeName);
                     if (type == null) {
@@ -231,13 +274,16 @@ 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);
+                            final Table table = analyzer.table(dependency, typeName, true);
+                            final String propertyName = (count++ == 0) ? column : column + '-' + count;
                             final AssociationRoleBuilder association;
                             if (table != null) {
+                                dependency.setRelatedTable(table, propertyName);
                                 association = feature.addAssociation(table.featureType);
                             } else {
                                 association = feature.addAssociation(typeName);     // May happen in case of cyclic dependency.
                             }
+                            association.setName(propertyName);
                             if (!mandatory) {
                                 association.setMinimumOccurs(0);
                             }
@@ -249,10 +295,9 @@ final class Table extends AbstractFeatureSet {
                              */
                             if (attribute != null) {
                                 attribute.setName(analyzer.nameFactory.createGenericName(null, "pk", column));
+                                attributeNames.set(attributeNames.size() - 1, attribute.getName().toString());
                                 attribute = null;
                             }
-                            association.setName((count == 0) ? column : column + '-' + count);
-                            count++;
                         }
                     }
                 }
@@ -267,27 +312,28 @@ final class Table extends AbstractFeatureSet {
         for (final Relation dependency : exportedKeys) {
             if (dependency != null) {
                 final GenericName typeName = dependency.getName(analyzer);
-                String name = typeName.tip().toString();
+                String propertyName = typeName.tip().toString();
                 if (startWithLowerCase > 0) {
-                    final CharSequence words = CharSequences.camelCaseToWords(name, true);
+                    final CharSequence words = CharSequences.camelCaseToWords(propertyName, true);
                     final int first = Character.codePointAt(words, 0);
-                    name = new StringBuilder(words.length())
+                    propertyName = new StringBuilder(words.length())
                             .appendCodePoint(Character.toLowerCase(first))
                             .append(words, Character.charCount(first), words.length())
                             .toString();
                 }
-                final String base = name;
-                while (feature.isNameUsed(name)) {
-                    name = base + '-' + ++count;
+                final String base = propertyName;
+                while (feature.isNameUsed(propertyName)) {
+                    propertyName = base + '-' + ++count;
                 }
-                final Table table = analyzer.table(dependency, typeName);
+                final Table table = analyzer.table(dependency, typeName, true);
                 final AssociationRoleBuilder association;
                 if (table != null) {
+                    dependency.setRelatedTable(table, propertyName);
                     association = feature.addAssociation(table.featureType);
                 } else {
                     association = feature.addAssociation(typeName);     // May happen in case of cyclic dependency.
                 }
-                association.setName(name)
+                association.setName(propertyName)
                            .setMinimumOccurs(0)
                            .setMaximumOccurs(Integer.MAX_VALUE);
             }
@@ -314,25 +360,37 @@ final class Table extends AbstractFeatureSet {
         if (remarks != null) {
             feature.setDefinition(remarks);
         }
-        this.featureType  = feature.build();
-        this.primaryKeys  = CollectionsExt.compact(primaryKeys);
-        this.importedKeys = CollectionsExt.compact(importedKeys);
-        this.exportedKeys = CollectionsExt.compact(exportedKeys);
-        this.hasGeometry  = hasGeometry;
+        this.featureType      = feature.build();
+        this.importedKeys     = toArray(importedKeys);
+        this.exportedKeys     = toArray(exportedKeys);
+        this.hasGeometry      = hasGeometry;
+        this.attributeNames   = attributeNames.toArray(new String[attributeNames.size()]);
+        this.attributeColumns = attributeColumns.equals(attributeNames) ? this.attributeNames
+                              : attributeColumns.toArray(new String[attributeColumns.size()]);
+    }
+
+    /**
+     * Returns the given relations as an array, or {@code null} if none.
+     */
+    private static Relation[] toArray(final Collection<Relation> relations) {
+        final int size = relations.size();
+        return (size != 0) ? relations.toArray(new Relation[size]) : null;
     }
 
     /**
      * Appends all children to the given parent. The children are added under the given node.
-     * If the children collection is empty, then this method does nothing.
+     * If the children array is null, then this method does nothing.
      *
      * @param  parent    the node where to add children.
-     * @param  children  the children to add, or an empty collection if none.
+     * @param  children  the children to add, or {@code null} if none.
      * @param  arrow     the symbol to use for relating the columns of two tables in a foreigner key.
      */
     @Debug
-    private static void appendAll(final TreeTable.Node parent, final Collection<Relation> children, final String arrow) {
-        for (final Relation child : children) {
-            child.appendTo(parent, arrow);
+    private static void appendAll(final TreeTable.Node parent, final Relation[] children, final String arrow) {
+        if (children != null) {
+            for (final Relation child : children) {
+                child.appendTo(parent, arrow);
+            }
         }
     }
 
@@ -344,13 +402,11 @@ final class Table extends AbstractFeatureSet {
     @Debug
     final void appendTo(TreeTable.Node parent) {
         parent = Relation.newChild(parent, featureType.getName().toString());
-        for (PropertyType p : featureType.getProperties(false)) {
-            if (p instanceof AttributeType) {
-                TableReference.newChild(parent, p.getName().tip().toString());
-            }
+        for (final String attribute : attributeNames) {
+            TableReference.newChild(parent, attribute);
         }
-        appendAll(parent, importedKeys,  " → ");
-        appendAll(parent, exportedKeys,  " ← ");
+        appendAll(parent, importedKeys, " → ");
+        appendAll(parent, exportedKeys, " ← ");
     }
 
     /**
@@ -407,7 +463,28 @@ final class Table extends AbstractFeatureSet {
      * @throws DataStoreException if an error occurred while creating the stream.
      */
     @Override
-    public Stream<Feature> features(boolean parallel) throws DataStoreException {
-        throw new UnsupportedOperationException("Not supported yet.");  // TODO
+    public Stream<Feature> features(final boolean parallel) throws DataStoreException {
+        DataStoreException ex;
+        Connection connection = null;
+        try {
+            connection = source.getConnection();
+            final Features iter = features(connection, null);
+            return StreamSupport.stream(iter, parallel).onClose(iter);
+        } catch (SQLException cause) {
+            ex = new DataStoreException(Exceptions.unwrap(cause));
+        }
+        if (connection != null) try {
+            connection.close();
+        } catch (SQLException e) {
+            ex.addSuppressed(e);
+        }
+        throw ex;
+    }
+
+    /**
+     * Returns an iterator over the features.
+     */
+    final Features features(final Connection connection, final Relation componentOf) throws SQLException {
+        return new Features(this, connection, attributeNames, attributeColumns, importedKeys, componentOf);
     }
 }
diff --git a/storage/sis-sqlstore/src/main/java/org/apache/sis/storage/sql/SQLStore.java b/storage/sis-sqlstore/src/main/java/org/apache/sis/storage/sql/SQLStore.java
index 129edf3..0db5080 100644
--- a/storage/sis-sqlstore/src/main/java/org/apache/sis/storage/sql/SQLStore.java
+++ b/storage/sis-sqlstore/src/main/java/org/apache/sis/storage/sql/SQLStore.java
@@ -42,8 +42,8 @@ import org.apache.sis.util.Exceptions;
 /**
  * A data store capable to read and create features from a spatial database.
  * {@code SQLStore} requires a {@link DataSource} to be specified (indirectly) at construction time.
- * The {@code DataSource} should provide pooled connections, since connections will be frequently
- * opened and closed.
+ * The {@code DataSource} should provide pooled connections, because {@code SQLStore} will frequently
+ * opens and closes them.
  *
  * @author  Johann Sorel (Geomatys)
  * @author  Martin Desruisseaux (Geomatys)
@@ -137,7 +137,7 @@ public class SQLStore extends DataStore implements Aggregate {
     private synchronized Database model() throws DataStoreException {
         if (model == null) {
             try (Connection c = source.getConnection()) {
-                model = new Database(this, c, tableNames, listeners);
+                model = new Database(this, c, source, tableNames, listeners);
             } catch (SQLException e) {
                 throw new DataStoreException(Exceptions.unwrap(e));
             }
@@ -153,7 +153,7 @@ public class SQLStore extends DataStore implements Aggregate {
      */
     private synchronized Database model(final Connection c) throws DataStoreException, SQLException {
         if (model == null) {
-            model = new Database(this, c, tableNames, listeners);
+            model = new Database(this, c, source, tableNames, listeners);
         }
         return model;
     }
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 8451362..e77a309 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
@@ -16,15 +16,18 @@
  */
 package org.apache.sis.storage.sql;
 
+import java.util.stream.Stream;
 import org.apache.sis.storage.FeatureSet;
 import org.apache.sis.storage.StorageConnector;
 import org.apache.sis.test.sql.TestDatabase;
 import org.apache.sis.test.TestCase;
 import org.junit.Test;
 
-import static org.junit.Assert.*;
+import static org.opengis.test.Assert.*;
 
 // Branch-dependent imports
+import org.opengis.feature.Feature;
+import org.opengis.feature.FeatureType;
 import org.opengis.feature.PropertyType;
 import org.opengis.feature.AttributeType;
 import org.opengis.feature.FeatureAssociationRole;
@@ -52,28 +55,100 @@ public final strictfp class SQLStoreTest extends TestCase {
                     SQLStoreProvider.createTableName(null, "features", "Cities")))
             {
                 final FeatureSet cities = (FeatureSet) store.findResource("Cities");
-                final String[] expectedNames = {"sis:identifier", "pk:country", "country",   "native_name", "translation", "population",  "parks"};
-                final Object[] expectedTypes = {null,             String.class, "Countries", String.class,  String.class,  Integer.class, "Parks"};
-                int i = 0;
-                for (PropertyType pt : cities.getType().getProperties(false)) {
-                    assertEquals("name", expectedNames[i], pt.getName().toString());
-                    final Object expectedType = expectedTypes[i];
-                    if (expectedType != null) {
-                        final String label;
-                        final Object value;
-                        if (expectedType instanceof Class<?>) {
-                            label = "attribute type";
-                            value = ((AttributeType<?>) pt).getValueClass();
-                        } else {
-                            label = "association type";
-                            value = ((FeatureAssociationRole) pt).getValueType().getName().toString();
-                        }
-                        assertEquals(label, expectedType, value);
-                    }
-                    i++;
+                verifyCityType(cities.getType());
+                try (Stream<Feature> features = cities.features(false)) {
+                    features.forEach(SQLStoreTest::verifyContent);
                 }
-                assertEquals("count", expectedNames.length, i);
             }
         }
     }
+
+    /**
+     * Verifies the result of analyzing the structure of the {@code "Cities"} table.
+     */
+    private static void verifyCityType(final FeatureType cities) {
+        final String[] expectedNames = {"sis:identifier", "pk:country", "country",   "native_name", "translation", "population",  "parks"};
+        final Object[] expectedTypes = {null,             String.class, "Countries", String.class,  String.class,  Integer.class, "Parks"};
+        int i = 0;
+        for (PropertyType pt : cities.getProperties(false)) {
+            assertEquals("name", expectedNames[i], pt.getName().toString());
+            final Object expectedType = expectedTypes[i];
+            if (expectedType != null) {
+                final String label;
+                final Object value;
+                if (expectedType instanceof Class<?>) {
+                    label = "attribute type";
+                    value = ((AttributeType<?>) pt).getValueClass();
+                } else {
+                    label = "association type";
+                    value = ((FeatureAssociationRole) pt).getValueType().getName().toString();
+                }
+                assertEquals(label, expectedType, value);
+            }
+            i++;
+        }
+        assertEquals("count", expectedNames.length, i);
+    }
+
+    /**
+     * Verifies the content of the {@code Cities} table.
+     * The features are in no particular order.
+     */
+    private static void verifyContent(final Feature feature) {
+        final String city = feature.getPropertyValue("native_name").toString();
+        final String country, countryName, cityLatin;
+        final int population;
+        switch (city) {
+            case "東京": {
+                cityLatin   = "Tōkyō";
+                country     = "JPN";
+                countryName = "日本";
+                population  = 13622267;         // In 2016.
+                break;
+            }
+            case "Paris": {
+                cityLatin   = "Paris";
+                country     = "FRA";
+                countryName = "France";
+                population  = 2206488;          // In 2017.
+                break;
+            }
+            case "Montréal": {
+                cityLatin   = "Montreal";
+                country     = "CAN";
+                countryName = "Canada";
+                population  = 1704694;          // In 2016.
+                break;
+            }
+            case "Québec": {
+                cityLatin   = "Quebec";
+                country     = "CAN";
+                countryName = "Canada";
+                population  = 531902;           // In 2016.
+                break;
+            }
+            default: {
+                fail("Unexpected feature: " + city);
+                return;
+            }
+        }
+        // Attributes
+        assertEquals("pk:country",     country,              feature.getPropertyValue("pk:country"));
+        assertEquals("sis:identifier", country + ':' + city, feature.getPropertyValue("sis:identifier"));
+        assertEquals("translation",    cityLatin,            feature.getPropertyValue("translation"));
+        assertEquals("population",     population,           feature.getPropertyValue("population"));
+
+        // Associations
+        assertEquals("country", countryName, getIndirectPropertyValue(feature, "country", "native_name"));
+    }
+
+    /**
+     * Follows an association in the given feature.
+     */
+    private static Object getIndirectPropertyValue(final Feature feature, final String p1, final String p2) {
+        final Object dependency = feature.getPropertyValue(p1);
+        assertNotNull(p1, dependency);
+        assertInstanceOf(p1, Feature.class, dependency);
+        return ((Feature) dependency).getPropertyValue(p2);
+    }
 }


Mime
View raw message