sis-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From desruisse...@apache.org
Subject [sis] 02/02: Move the analysis of primary/foreigner keys in the Relation table.
Date Fri, 06 Jul 2018 14:46:26 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

commit 1cc76fd6399bb33a7eaba97ef3b63a909dc595cc
Author: Martin Desruisseaux <martin.desruisseaux@geomatys.com>
AuthorDate: Fri Jul 6 16:45:37 2018 +0200

    Move the analysis of primary/foreigner keys in the Relation table.
---
 .../sis/internal/metadata/sql/Reflection.java      |   6 +
 .../apache/sis/internal/sql/feature/Column.java    |   7 -
 .../apache/sis/internal/sql/feature/Database.java  | 196 +++++++++------------
 .../apache/sis/internal/sql/feature/MetaModel.java |  27 +--
 .../apache/sis/internal/sql/feature/Relation.java  | 172 +++++++++++++++---
 .../apache/sis/internal/sql/feature/Resources.java | 140 +++++++++++++++
 .../sis/internal/sql/feature/Resources.properties  |  22 +++
 .../internal/sql/feature/Resources_fr.properties   |  27 +++
 .../apache/sis/internal/sql/feature/Schema.java    |  44 +++--
 .../sis/internal/sql/feature/SpatialFunctions.java |   6 +-
 .../org/apache/sis/internal/sql/feature/Table.java |  63 +++----
 .../sis/internal/sql/feature/package-info.java     |   2 +-
 12 files changed, 504 insertions(+), 208 deletions(-)

diff --git a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/Reflection.java b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/Reflection.java
index 71ea48a..cfd27ca 100644
--- a/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/Reflection.java
+++ b/core/sis-metadata/src/main/java/org/apache/sis/internal/metadata/sql/Reflection.java
@@ -147,6 +147,12 @@ public final class Reflection {
     public static final String FK_NAME = "FK_NAME";
 
     /**
+     * The {@value} key for the foreigner key table catalog.
+     * Values in this column may be null.
+     */
+    public static final String FKTABLE_CAT = "FKTABLE_CAT";
+
+    /**
      * The {@value} key for the foreign key table schema.
      * Values in this column may be null.
      */
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Column.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Column.java
index 85afb3a..6916bde 100644
--- a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Column.java
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Column.java
@@ -45,13 +45,6 @@ final class Column {
             Boolean.class, 1, 1, null);
 
     /**
-     * Property information, if the field is a relation.
-     */
-    static final AttributeType<Relation> JDBC_PROPERTY_RELATION = new DefaultAttributeType<>(
-            Collections.singletonMap(DefaultAttributeType.NAME_KEY, "relation"),
-            Relation.class, 1, 1, null);
-
-    /**
      * Whether values in a column are generated by the database, computed from a sequence of supplied.
      */
     enum Type {
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Database.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Database.java
index bfdd113..4ed2102 100644
--- a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Database.java
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Database.java
@@ -25,11 +25,9 @@ import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
+import java.util.Iterator;
 import java.util.List;
-import java.util.Map.Entry;
 import java.util.Map;
-import java.util.Set;
 import org.opengis.util.GenericName;
 import org.opengis.coverage.Coverage;
 import org.opengis.feature.AttributeType;
@@ -69,23 +67,21 @@ public final class Database {
     private static final String TABLE = "TABLE", VIEW = "VIEW";
 
     /**
-     * Feature type used to mark types which are sub-types of others.
+     * Abstract type used to mark features that are components of other features.
+     *
+     * @deprecated replace by scoped name (TODO).
      */
-    private static final FeatureType SUBTYPE;
-    static {
-        final FeatureTypeBuilder ftb = new FeatureTypeBuilder()
-                .setName("SubType")
-                .setAbstract(true);
-        SUBTYPE = ftb.build();
-    }
+    @Deprecated
+    private static final FeatureType COMPONENT = new FeatureTypeBuilder().setName("Component").setAbstract(true).build();
 
     private final SpatialFunctions functions;
     private final FeatureNaming<PrimaryKey> pkIndex;
     private final FeatureNaming<FeatureType> typeIndex;
     private final Map<String,Schema> schemas;
 
-    public Database(final SQLStore store, final SpatialFunctions functions, final String schema, final String table,
-            final List<String> addWarningsTo) throws SQLException, IllegalNameException
+    public Database(final SQLStore store, final SpatialFunctions functions, final String catalog,
+            final String schema, final String table, final List<String> addWarningsTo)
+            throws SQLException, DataStoreException
     {
         if (table != null) {
             ArgumentChecks.ensureNonEmpty("table", table);
@@ -94,19 +90,7 @@ public final class Database {
         pkIndex = new FeatureNaming<>();
         typeIndex = new FeatureNaming<>();
         schemas = new HashMap<>();
-        analyze(store, schema, table, addWarningsTo);
-    }
-
-    private Collection<Schema> getSchemaMetaModels() {
-        return schemas.values();
-    }
-
-    private Schema getSchemaMetaModel(String name) {
-        return schemas.get(name);
-    }
-
-    private PrimaryKey getPrimaryKey(final SQLStore store, final String featureTypeName) throws IllegalNameException {
-        return pkIndex.get(store, featureTypeName);
+        analyze(store, catalog, schema, table, addWarningsTo);
     }
 
     public FeatureType getFeatureType(final SQLStore store, final String typeName) throws IllegalNameException {
@@ -116,35 +100,45 @@ public final class Database {
     /**
      * Explores all tables and views then recreate a complex feature model from relations.
      */
-    private synchronized void analyze(final SQLStore store, final String schemaName, final String tableName, final List<String> addWarningsTo)
-            throws SQLException, IllegalNameException
+    private synchronized void analyze(final SQLStore store, final String catalog, final String schemaName,
+            final String tableName, final List<String> addWarningsTo)
+            throws SQLException, DataStoreException
     {
         try (Connection cx = store.getDataSource().getConnection()) {
             final DatabaseMetaData metadata = cx.getMetaData();
-            final Set<String> requieredSchemas = new HashSet<>();
-            final Set<String> visitedSchemas = new HashSet<>();
+            /*
+             * Keep trace of the schemas that we need to visit, and the schema already visited.
+             * The boolean value tells whether the schema has already been visited or not.
+             * New schemas to visit may be added when following the relation established by foreigner keys.
+             */
+            final Map<String,Boolean> requiredSchemas = new HashMap<>();
             /*
              * Schema names available in the database:
              * 1. TABLE_SCHEM   : String  =>  schema name
              * 2. TABLE_CATALOG : String  =>  catalog name (may be null)
              */
             if (schemaName != null) {
-                requieredSchemas.add(schemaName);
+                requiredSchemas.put(schemaName, Boolean.FALSE);
             } else try (ResultSet reflect = metadata.getSchemas()) {
+                // TODO: use schemas in getTables instead.
                 while (reflect.next()) {
-                    requieredSchemas.add(reflect.getString(Reflection.TABLE_SCHEM));        // TODO: use schemas in getTables instead.
+                    requiredSchemas.put(reflect.getString(Reflection.TABLE_SCHEM), Boolean.FALSE);
                 }
             }
             /*
-             * We need to analyze requiered schema references.
+             * Iterate over all schemas that we need to process. We may need to stop iteration and recreate
+             * a new iterator because the methods invoked in this loop may alter the map content.
+             *
+             * TODO: use a boolean return value telling us if we need to recreate the iterator.
              */
-            while (!requieredSchemas.isEmpty()) {
-                final String sn = requieredSchemas.iterator().next();
-                visitedSchemas.add(sn);
-                requieredSchemas.remove(sn);
-                // TODO: escape with metadata.getSearchStringEscape().
-                final Schema schema = analyzeSchema(metadata, sn, tableName, requieredSchemas, visitedSchemas, addWarningsTo);
-                schemas.put(schema.name, schema);
+            Iterator<Map.Entry<String,Boolean>> it;
+            while ((it = requiredSchemas.entrySet().iterator()).hasNext()) {
+                final Map.Entry<String,Boolean> sn = it.next();
+                if (!sn.setValue(Boolean.TRUE)) {
+                    // TODO: escape with metadata.getSearchStringEscape().
+                    final Schema schema = analyzeSchema(metadata, catalog, sn.getKey(), tableName, requiredSchemas, addWarningsTo);
+                    schemas.put(schema.name, schema);
+                }
             }
             reverseSimpleFeatureTypes(metadata);
         }
@@ -153,35 +147,33 @@ public final class Database {
          */
         final Collection<Schema> candidates;
         if (schemaName == null) {
-            candidates = getSchemaMetaModels();             // Take all schemas.
+            candidates = schemas.values();             // Take all schemas.
         } else {
-            candidates = Collections.singleton(getSchemaMetaModel(schemaName));
+            candidates = Collections.singleton(schemas.get(schemaName));
         }
         for (Schema schema : candidates) {
-           if (schema != null) {
-                for (Table table : schema.getTables()) {
-
-                    final FeatureTypeBuilder ft = table.getType(Table.View.SIMPLE_FEATURE_TYPE);
-                    final GenericName name = ft.getName();
-                    pkIndex.add(store, name, table.key);
-                    if (table.isSubType()) {
-                        // We don't show subtype, they are part of other feature types, add a flag to identify then
-                        ft.setSuperTypes(SUBTYPE);
-                    }
-                    typeIndex.add(store, name, ft.build());
-                 }
-            } else {
+            if (schema == null) {
                 throw new SQLException("Specifed schema " + schemaName + " does not exist.");
             }
+            for (Table table : schema.getTables()) {
+                final FeatureTypeBuilder ft = table.featureType;
+                final GenericName name = ft.getName();
+                pkIndex.add(store, name, table.key);
+                if (table.isComponent()) {
+                    // We don't show subtype, they are part of other feature types, add a flag to identify then
+                    ft.setSuperTypes(COMPONENT);
+                }
+                typeIndex.add(store, name, ft.build());
+             }
         }
     }
 
     /**
      * @param  schemaPattern  schema name with "%" and "_" interpreted as wildcards, or {@code null} for all schemas.
      */
-    private Schema analyzeSchema(final DatabaseMetaData metadata, final String schemaPattern, final String tableNamePattern,
-            final Set<String> requieredSchemas, final Set<String> visitedSchemas, final List<String> addWarningsTo)
-            throws SQLException, IllegalNameException
+    private Schema analyzeSchema(final DatabaseMetaData metadata, final String catalog, final String schemaPattern,
+            final String tableNamePattern, final Map<String,Boolean> requiredSchemas,
+            final List<String> addWarningsTo) throws SQLException, DataStoreException
     {
         final Schema schema = new Schema(schemaPattern);
         /*
@@ -190,28 +182,28 @@ public final class Database {
          * 2. TABLE_NAME  : String  =>  table name
          * 3. TABLE_TYPE  : String  =>  table type (typically "TABLE" or "VIEW").
          */
-        try (ResultSet reflect = metadata.getTables(null, schemaPattern, tableNamePattern, new String[] {TABLE, VIEW})) {   // TODO: use metadata.getTableTypes()
+        try (ResultSet reflect = metadata.getTables(catalog, schemaPattern, tableNamePattern, new String[] {TABLE, VIEW})) {   // TODO: use metadata.getTableTypes()
             while (reflect.next()) {
-                final Table table = analyzeTable(metadata, reflect, requieredSchemas, visitedSchemas, addWarningsTo);
-                schema.tables.put(table.name, table);
+                schema.addTable(analyzeTable(metadata, reflect, requiredSchemas, addWarningsTo));
             }
         }
         return schema;
     }
 
     private Table analyzeTable(final DatabaseMetaData metadata, final ResultSet tableSet,
-            final Set<String> requieredSchemas, final Set<String> visitedSchemas, final List<String> addWarningsTo)
-            throws SQLException, IllegalNameException
+            final Map<String,Boolean> requiredSchemas, final List<String> addWarningsTo)
+            throws SQLException, DataStoreException
     {
+        final String catalog    = tableSet.getString(Reflection.TABLE_CAT);
         final String schemaName = tableSet.getString(Reflection.TABLE_SCHEM);
         final String tableName  = tableSet.getString(Reflection.TABLE_NAME);
         final String tableType  = tableSet.getString(Reflection.TABLE_TYPE);
-        final Table table = new Table(tableName, tableType);
+        final Table table = new Table(tableName);
         final FeatureTypeBuilder ftb = new FeatureTypeBuilder();
         /*
          * Explore all columns.
          */
-        try (ResultSet reflect = metadata.getColumns(null, schemaName, tableName, null)) {
+        try (ResultSet reflect = metadata.getColumns(catalog, schemaName, tableName, null)) {
             while (reflect.next()) {
                 analyzeColumn(metadata, reflect, ftb.addAttribute(Object.class));
             }
@@ -220,11 +212,11 @@ public final class Database {
          * Find primary keys.
          */
         final List<Column> cols = new ArrayList<>();
-        try (ResultSet rp = metadata.getPrimaryKeys(null, schemaName, tableName)) {
+        try (ResultSet rp = metadata.getPrimaryKeys(catalog, schemaName, tableName)) {
             while (rp.next()) {
                 final String columnNamePattern = rp.getString(Reflection.COLUMN_NAME);
                 // TODO: escape columnNamePattern with metadata.getSearchStringEscape().
-                try (ResultSet reflect = metadata.getColumns(null, schemaName, tableName, columnNamePattern)) {
+                try (ResultSet reflect = metadata.getColumns(catalog, schemaName, tableName, columnNamePattern)) {
                     while (reflect.next()) {                                        // Should loop exactly once.
                         final int sqlType = reflect.getInt(Reflection.DATA_TYPE);
                         final String sqlTypeName = reflect.getString(Reflection.TYPE_NAME);
@@ -263,7 +255,7 @@ public final class Database {
         final Map<String,List<String>> uniqueIndexes = new HashMap<>();
         String indexname = null;
         // We can't cache this one, seems to be a bug in the driver, it won't find anything for table name like '%'
-        try (ResultSet reflect = metadata.getIndexInfo(null, schemaName, tableName, true, false)) {
+        try (ResultSet reflect = metadata.getIndexInfo(catalog, schemaName, tableName, true, false)) {
             while (reflect.next()) {
                 final String columnName = reflect.getString(Reflection.COLUMN_NAME);
                 final String idxName = reflect.getString(Reflection.INDEX_NAME);
@@ -290,7 +282,7 @@ public final class Database {
         /*
          * For each unique index composed of one column add a flag on the property descriptor.
          */
-        for (Entry<String,List<String>> entry : uniqueIndexes.entrySet()) {
+        for (Map.Entry<String,List<String>> entry : uniqueIndexes.entrySet()) {
             final List<String> columns = entry.getValue();
             if (columns.size() == 1) {
                 String columnName = columns.get(0);
@@ -306,7 +298,7 @@ public final class Database {
             /*
              * Build a primary key from unique index.
              */
-            try (ResultSet reflect = metadata.getColumns(null, schemaName, tableName, null)) {
+            try (ResultSet reflect = metadata.getColumns(catalog, schemaName, tableName, null)) {
                 while (reflect.next()) {
                     final String columnName = reflect.getString(Reflection.COLUMN_NAME);
                     if (names.contains(columnName)) {
@@ -349,49 +341,31 @@ public final class Database {
             }
         }
         /*
-         * Find imported keys.
+         * Creates a list of associations between the table read by this method and other tables.
+         * The associations are defined by the foreigner keys referencing primary keys. Note that
+         * the table relations can be defined in both ways:  the foreigner keys of this table may
+         * be referencing the primary keys of other tables (Direction.IMPORT) or the primary keys
+         * of this table may be referenced by the foreigner keys of other tables (Direction.EXPORT).
+         * However in both case, we will translate that into associations from this table to the
+         * other tables. We can not rely on IMPORT versus EXPORT for determining the association
+         * navigability because the database designer's choice may be driven by the need to support
+         * multi-occurrences.
          */
-        try (ResultSet reflect = metadata.getImportedKeys(null, schemaName, tableName)) {
-            while (reflect.next()) {
-                String relationName = reflect.getString(Reflection.PK_NAME);
-                if (relationName == null) relationName = reflect.getString(Reflection.FK_NAME);
-                final String localColumn   = reflect.getString(Reflection.FKCOLUMN_NAME);
-                final String refSchemaName = reflect.getString(Reflection.PKTABLE_SCHEM);
-                final String refTableName  = reflect.getString(Reflection.PKTABLE_NAME);
-                final String refColumnName = reflect.getString(Reflection.PKCOLUMN_NAME);
-                final int deleteRule = reflect.getInt(Reflection.DELETE_RULE);
-                final boolean deleteCascade = DatabaseMetaData.importedKeyCascade == deleteRule;
-                final Relation relation = new Relation(relationName,localColumn,
-                        refSchemaName, refTableName, refColumnName, true, deleteCascade);
+        try (ResultSet reflect = metadata.getImportedKeys(catalog, schemaName, tableName)) {
+            while (!reflect.isClosed()) {
+                final Relation relation = new Relation(Relation.Direction.IMPORT, reflect);
                 table.importedKeys.add(relation);
-                if (refSchemaName!=null && !visitedSchemas.contains(refSchemaName)) requieredSchemas.add(refSchemaName);
-                for (PropertyTypeBuilder desc : ftb.properties()) {
-                    if (desc.getName().tip().toString().equals(localColumn)) {
-                        final AttributeTypeBuilder<?> atb = (AttributeTypeBuilder) desc;
-                        atb.addCharacteristic(Column.JDBC_PROPERTY_RELATION).setDefaultValue(relation);
-                        break;
-                    }
+                if (relation.schema != null) {
+                    requiredSchemas.putIfAbsent(relation.schema, Boolean.FALSE);
                 }
             }
         }
-        /*
-         * Find exported keys.
-         */
-        try (ResultSet reflect = metadata.getExportedKeys(null, schemaName, tableName)) {
-            while (reflect.next()) {
-                String relationName = reflect.getString(Reflection.FKCOLUMN_NAME);
-                if (relationName == null) relationName = reflect.getString(Reflection.FK_NAME);
-                final String localColumn   = reflect.getString(Reflection.PKCOLUMN_NAME);
-                final String refSchemaName = reflect.getString(Reflection.FKTABLE_SCHEM);
-                final String refTableName  = reflect.getString(Reflection.FKTABLE_NAME);
-                final String refColumnName = reflect.getString(Reflection.FKCOLUMN_NAME);
-                final int deleteRule = reflect.getInt(Reflection.DELETE_RULE);
-                final boolean deleteCascade = DatabaseMetaData.importedKeyCascade == deleteRule;
-                table.exportedKeys.add(new Relation(relationName, localColumn,
-                        refSchemaName, refTableName, refColumnName, false, deleteCascade));
-
-                if (refSchemaName != null && !visitedSchemas.contains(refSchemaName)) {
-                    requieredSchemas.add(refSchemaName);
+        try (ResultSet reflect = metadata.getExportedKeys(catalog, schemaName, tableName)) {
+            while (!reflect.isClosed()) {
+                final Relation relation = new Relation(Relation.Direction.IMPORT, reflect);
+                table.exportedKeys.add(relation);
+                if (relation.schema != null) {
+                    requiredSchemas.putIfAbsent(relation.schema, Boolean.FALSE);
                 }
             }
         }
@@ -436,12 +410,12 @@ public final class Database {
 
             // Search if we already have this property
             PropertyType desc = null;
-            final Schema schema = getSchemaMetaModel(schemaName);
+            final Schema schema = schemas.get(schemaName);
             if (schema != null) {
                 Table table = schema.getTable(tableName);
                 if (table != null) {
                     try {
-                        desc = table.getType(Table.View.SIMPLE_FEATURE_TYPE).build().getProperty(columnName);
+                        desc = table.featureType.build().getProperty(columnName);
                     } catch (PropertyNotFoundException ex) {
                         // ok
                     }
@@ -498,7 +472,7 @@ public final class Database {
                         }
                     }
                 }
-                table.simpleFeatureType = ftb;
+                table.featureType = ftb;
             }
         }
     }
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/MetaModel.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/MetaModel.java
index fd7975d..675ea71 100644
--- a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/MetaModel.java
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/MetaModel.java
@@ -19,6 +19,7 @@ package org.apache.sis.internal.sql.feature;
 import java.util.Collection;
 import java.sql.DatabaseMetaData;
 import org.apache.sis.util.Debug;
+import org.apache.sis.util.resources.Vocabulary;
 import org.apache.sis.util.collection.TreeTable;
 import org.apache.sis.util.collection.TableColumn;
 import org.apache.sis.util.collection.DefaultTreeTable;
@@ -26,24 +27,26 @@ import org.apache.sis.util.collection.DefaultTreeTable;
 
 /**
  * Description about a database entity (schema, table, relation, <i>etc</i>).
- * The information provided by subclasses are inferred from {@link DatabaseMetaData}
- * and stored as structures from the {@link org.apache.sis.feature} package.
+ * Information provided by subclasses are inferred from {@link DatabaseMetaData}
+ * and stored as {@link org.apache.sis.feature} classes.
  *
  * @author  Johann Sorel (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
  * @version 1.0
  * @since   1.0
  * @module
  */
 abstract class MetaModel {
     /**
-     * The entity (schema, table, <i>etc</i>) name.
+     * The entity name (schema, table, <i>etc</i>).
+     * May be null, for example if this is the primary key name and that name is unspecified.
      */
     final String name;
 
     /**
      * Creates a new object describing a database entity (schema, table, <i>etc</i>).
      *
-     * @param  name  the database entity name.
+     * @param  name  the database entity name, or {@code null} if unspecified.
      */
     MetaModel(final String name) {
         this.name = name;
@@ -52,7 +55,8 @@ abstract class MetaModel {
     /**
      * Creates a tree representation of this object for debugging purpose.
      * The default implementation adds a single node with the {@link #name} of this entity
-     * and returns that node. Subclasses can override for appending additional information.
+     * and returns that node. Subclasses can override this method for appending additional
+     * information.
      *
      * @param  parent  the parent node where to add the tree representation.
      * @return the node added by this method.
@@ -63,22 +67,23 @@ abstract class MetaModel {
     }
 
     /**
-     * Add a child of the given name to the given node.
+     * Adds a child of the given name to the given parent node.
+     * This is a convenience method for {@link #appendTo(TreeTable.Node)} implementations.
      *
      * @param  parent  the node where to add a child.
      * @param  name    the name to assign to the child.
-     * @return the child node.
+     * @return the child added to the parent.
      */
     @Debug
-    private static TreeTable.Node newChild(final TreeTable.Node parent, final String name) {
+    static TreeTable.Node newChild(final TreeTable.Node parent, final String name) {
         final TreeTable.Node child = parent.newChild();
-        child.setValue(TableColumn.NAME, name);
+        child.setValue(TableColumn.NAME, (name != null) ? name : Vocabulary.format(Vocabulary.Keys.Unnamed));
         return child;
     }
 
     /**
      * Appends all children to the given parent. The children are added under a node of the given name.
-     * If the collection of children is empty, then no node of the given {@code name} is inserted.
+     * If the children collection is empty, then this method does nothing.
      *
      * @param  parent    the node where to add children.
      * @param  name      the name of a node to insert between the parent and the children, or {@code null} if none.
@@ -102,7 +107,7 @@ abstract class MetaModel {
      * uses a monospaced font and supports Unicode.
      */
     @Override
-    public String toString() {
+    public final String toString() {
         final DefaultTreeTable table = new DefaultTreeTable(TableColumn.NAME);
         appendTo(table.getRoot());
         return table.toString();
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Relation.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Relation.java
index a395f63..8076ef0 100644
--- a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Relation.java
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Relation.java
@@ -16,43 +16,169 @@
  */
 package org.apache.sis.internal.sql.feature;
 
+import java.util.Map;
+import java.util.LinkedHashMap;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.DatabaseMetaData;
+import org.apache.sis.util.Debug;
+import org.apache.sis.util.collection.TreeTable;
+import org.apache.sis.internal.util.CollectionsExt;
+import org.apache.sis.internal.metadata.sql.Reflection;
+import org.apache.sis.storage.DataStoreContentException;
+
 
 /**
- * Description of a relation between two tables.
+ * Description of a relation between two tables, as defined by foreigner keys.
+ * Each {@link Table} may contain an arbitrary amount of relations.
+ * Relations are defined in two directions:
+ *
+ * <ul>
+ *   <li>{@link Direction#IMPORT}: primary keys of <em>other</em> tables are referenced by the foreigner keys
+ *       of the table containing this {@code Relation}.</li>
+ *   <li>{@link Direction#EXPORT}: foreigner keys of <em>other</em> tables are referencing the primary keys
+ *       of the table containing this {@code Relation}.</li>
+ * </ul>
+ *
+ * Instances of this class are created from the results of {@link DatabaseMetaData#getImportedKeys​ getImportedKeys​}
+ * or {@link DatabaseMetaData#getExportedKeys​ getExportedKeys​} with {@code (catalog, schema, table)} parameters.
  *
  * @author  Johann Sorel (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
  * @version 1.0
  * @since   1.0
  * @module
  */
 final class Relation extends MetaModel {
+    /**
+     * Whether another table is <em>using</em> or is <em>used by</em> the table containing the {@link Relation}.
+     */
+    enum Direction {
+        /**
+         * Primary keys of other tables are referenced by the foreigner keys of the table containing the {@code Relation}.
+         * In other words, the table containing {@code Relation} is <em>using</em> the {@link Relation#table}.
+         *
+         * @see DatabaseMetaData#getImportedKeys(String, String, String)
+         */
+        IMPORT(Reflection.PK_NAME, Reflection.PKTABLE_CAT, Reflection.PKTABLE_SCHEM,
+               Reflection.PKTABLE_NAME, Reflection.PKCOLUMN_NAME, Reflection.FKCOLUMN_NAME),
+
+        /**
+         * Foreigner keys of other tables are referencing the primary keys of the table containing the {@code Relation}.
+         * In other words, the table containing {@code Relation} is <em>used by</em> {@link Relation#table}.
+         *
+         * @see DatabaseMetaData#getExportedKeys(String, String, String)
+         */
+        EXPORT(Reflection.FK_NAME, Reflection.FKTABLE_CAT, Reflection.FKTABLE_SCHEM,
+               Reflection.FKTABLE_NAME, Reflection.FKCOLUMN_NAME, Reflection.PKCOLUMN_NAME);
+
+        /**
+         * The database {@link Reflection} key to use for fetching the name of a relation.
+         * The name is used only for informative purpose and may be {@code null}.
+         */
+        final String name;
+
+        /**
+         * The database {@link Reflection} key to use for fetching the name of other table column.
+         * That column is part of a primary key if the direction is {@link #IMPORT}, or part of a
+         * foreigner key if the direction is {@link #EXPORT}.
+         */
+        final String catalog, schema, table, column;
 
-    final String  currentColumn;
-    final String  foreignSchema;
-    final String  foreignTable;
-    final String  foreignColumn;
-    final boolean isImported;
+        /**
+         * The database {@link Reflection} key to use for fetching the name of the column in the table
+         * containing the {@code Relation}. That column is part of a foreigner key if the direction is
+         * {@link #IMPORT}, or part of a primary key if the direction is {@link #EXPORT}.
+         */
+        final String containerColumn;
+
+        /**
+         * Creates a new {@code Direction} enumeration value.
+         */
+        private Direction(final String name, final String catalog, final String schema,
+                          final String table, final String column, final String containerColumn)
+        {
+            this.name            = name;
+            this.catalog         = catalog;
+            this.schema          = schema;
+            this.table           = table;
+            this.column          = column;
+            this.containerColumn = containerColumn;
+        }
+    }
+
+    /**
+     * The catalog, schema and table name of the other table.
+     */
+    final String catalog, schema, table;
+
+    /**
+     * 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}.
+     */
+    private final Map<String,String> columns;
+
+    /**
+     * Whether entries in foreigner table will be deleted if the primary keys in the referenced table is deleted.
+     * This is used as a hint for detecting what may be the "main" table in a relation.
+     */
     final boolean cascadeOnDelete;
 
-    Relation(final String name, final String currentColumn, final String foreignSchema,
-            final String foreignTable, final String foreignColumn,
-            final boolean isImported, final boolean cascadeOnDelete)
-    {
-        super(name);
-        this.currentColumn   = currentColumn;
-        this.foreignSchema   = foreignSchema;
-        this.foreignTable    = foreignTable;
-        this.foreignColumn   = foreignColumn;
-        this.isImported      = isImported;
-        this.cascadeOnDelete = cascadeOnDelete;
+    /**
+     * 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:
+     *
+     * <ol>
+     *   <li>{@link Direction#catalog}</li>
+     *   <li>{@link Direction#schema}</li>
+     *   <li>{@link Direction#table}</li>
+     * </ol>
+     *
+     * Note that JDBC specification ensures this order if {@link Direction#IMPORT} is used with the result of
+     * {@code getImportedKeys​} and {@link Direction#EXPORT} is used with the result of {@code getExportedKeys​}.
+     *
+     * <p>After construction, the {@code ResultSet} will be positioned on the first row of the next relation,
+     * or be closed if the last row has been reached. This constructor always moves the given result set by at
+     * least one row, unless an exception occurs.</p>
+     */
+    Relation(final Direction dir, final ResultSet reflect) throws SQLException, DataStoreContentException {
+        super(reflect.getString(dir.name));
+        catalog = reflect.getString(dir.catalog);
+        schema  = reflect.getString(dir.schema);
+        table   = reflect.getString(dir.table);
+        final Map<String,String> m = new LinkedHashMap<>();
+        boolean cascade = false;
+        do {
+            final String column = reflect.getString(dir.column);
+            if (m.put(column, reflect.getString(dir.containerColumn)) != null) {
+                throw new DataStoreContentException(Resources.format(Resources.Keys.DuplicatedEntity_2, "Column", column));
+            }
+            if (!cascade) {
+                cascade = reflect.getInt(Reflection.DELETE_RULE) == DatabaseMetaData.importedKeyCascade;
+            }
+            if (!reflect.next()) {
+                reflect.close();
+                break;
+            }
+        } while (reflect.getString(dir.table)   == table &&
+                 reflect.getString(dir.schema)  == schema &&
+                 reflect.getString(dir.catalog) == catalog);
+
+        columns = CollectionsExt.compact(m);
+        cascadeOnDelete = cascade;
     }
 
+    /**
+     * Creates a tree representation of this relation for debugging purpose.
+     */
+    @Debug
     @Override
-    public String toString() {
-        return new StringBuilder(currentColumn)
-                .append(isImported ? " → " : " ← ")
-                .append(foreignSchema).append('.')
-                .append(foreignTable).append('.').append(foreignColumn)
-                .toString();
+    TreeTable.Node appendTo(final TreeTable.Node parent) {
+        final TreeTable.Node node = super.appendTo(parent);
+        for (final Map.Entry<String,String> e : columns.entrySet()) {
+            newChild(node, e.getValue() + " → " + e.getKey());
+        }
+        return node;
     }
 }
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources.java
new file mode 100644
index 0000000..5c4a910
--- /dev/null
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources.java
@@ -0,0 +1,140 @@
+/*
+ * 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.net.URL;
+import java.util.Locale;
+import java.util.MissingResourceException;
+import javax.annotation.Generated;
+import org.apache.sis.util.resources.KeyConstants;
+import org.apache.sis.util.resources.IndexedResourceBundle;
+
+
+/**
+ * Warning and error messages that are specific to the {@code sis-sql} module.
+ * Resources in this file should not be used by any other module. For resources shared by
+ * all modules in the Apache SIS project, see {@link org.apache.sis.util.resources} package.
+ *
+ * @author  Martin Desruisseaux (Geomatys)
+ * @version 1.0
+ * @since   1.0
+ * @module
+ */
+public final class Resources extends IndexedResourceBundle {
+    /**
+     * Resource keys. This class is used when compiling sources, but no dependencies to
+     * {@code Keys} should appear in any resulting class files. Since the Java compiler
+     * inlines final integer values, using long identifiers will not bloat the constant
+     * pools of compiled classes.
+     *
+     * @author  Martin Desruisseaux (IRD, Geomatys)
+     * @since   1.0
+     * @module
+     */
+    @Generated("org.apache.sis.util.resources.IndexedResourceCompiler")
+    public static final class Keys extends KeyConstants {
+        /**
+         * The unique instance of key constants handler.
+         */
+        static final Keys INSTANCE = new Keys();
+
+        /**
+         * For {@link #INSTANCE} creation only.
+         */
+        private Keys() {
+        }
+
+        /**
+         * Unexpected duplication of “{0}” entity named “{1}”.
+         */
+        public static final short DuplicatedEntity_2 = 1;
+    }
+
+    /**
+     * Constructs a new resource bundle loading data from the given UTF file.
+     *
+     * @param resources  the path of the binary file containing resources, or {@code null} if
+     *        there is no resources. The resources may be a file or an entry in a JAR file.
+     */
+    public Resources(final URL resources) {
+        super(resources);
+    }
+
+    /**
+     * Returns the handle for the {@code Keys} constants.
+     *
+     * @return a handler for the constants declared in the inner {@code Keys} class.
+     */
+    @Override
+    protected KeyConstants getKeyConstants() {
+        return Keys.INSTANCE;
+    }
+
+    /**
+     * Returns resources in the given locale.
+     *
+     * @param  locale  the locale, or {@code null} for the default locale.
+     * @return resources in the given locale.
+     * @throws MissingResourceException if resources can not be found.
+     */
+    public static Resources forLocale(final Locale locale) throws MissingResourceException {
+        return getBundle(Resources.class, locale);
+    }
+
+    /**
+     * Gets a string for the given key from this resource bundle or one of its parents.
+     *
+     * @param  key  the key for the desired string.
+     * @return the string for the given key.
+     * @throws MissingResourceException if no object for the given key can be found.
+     */
+    public static String format(final short key) throws MissingResourceException {
+        return forLocale(null).getString(key);
+    }
+
+    /**
+     * Gets a string for the given key are replace all occurrence of "{0}"
+     * with values of {@code arg0}.
+     *
+     * @param  key   the key for the desired string.
+     * @param  arg0  value to substitute to "{0}".
+     * @return the formatted string for the given key.
+     * @throws MissingResourceException if no object for the given key can be found.
+     */
+    public static String format(final short  key,
+                                final Object arg0) throws MissingResourceException
+    {
+        return forLocale(null).getString(key, arg0);
+    }
+
+    /**
+     * Gets a string for the given key are replace all occurrence of "{0}",
+     * "{1}", with values of {@code arg0}, {@code arg1}.
+     *
+     * @param  key   the key for the desired string.
+     * @param  arg0  value to substitute to "{0}".
+     * @param  arg1  value to substitute to "{1}".
+     * @return the formatted string for the given key.
+     * @throws MissingResourceException if no object for the given key can be found.
+     */
+    public static String format(final short  key,
+                                final Object arg0,
+                                final Object arg1) throws MissingResourceException
+    {
+        return forLocale(null).getString(key, arg0, arg1);
+    }
+}
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources.properties b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources.properties
new file mode 100644
index 0000000..3e2e217
--- /dev/null
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources.properties
@@ -0,0 +1,22 @@
+#
+# 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.
+#
+
+#
+# Resources in this file are for "sis-sql" usage only and should not be used by any other module.
+# For resources shared by all modules in the Apache SIS project, see "org.apache.sis.util.resources" package.
+#
+DuplicatedEntity_2                = Unexpected duplication of \u201c{0}\u201d entity named \u201c{1}\u201d.
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources_fr.properties b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources_fr.properties
new file mode 100644
index 0000000..cd0f2b9
--- /dev/null
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Resources_fr.properties
@@ -0,0 +1,27 @@
+#
+# 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.
+#
+
+#
+# Resources in this file are for "sis-sql" usage only and should not be used by any other module.
+# For resources shared by all modules in the Apache SIS project, see "org.apache.sis.util.resources" package.
+#
+# Punctuation rules in French (source: http://unicode.org/udhr/n/notes_fra.html)
+#
+#   U+202F NARROW NO-BREAK SPACE  before  ; ! and ?
+#   U+00A0 NO-BREAK SPACE         before  :
+#
+DuplicatedEntity_2                = Doublon inattendu d\u2019une entit\u00e9 \u00ab\u202f{0}\u202f\u00bb nomm\u00e9e \u00ab\u202f{1}\u202f\u00bb.
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Schema.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Schema.java
index 0d0d7a2..cc6001b 100644
--- a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Schema.java
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Schema.java
@@ -17,53 +17,67 @@
 package org.apache.sis.internal.sql.feature;
 
 import java.util.Map;
-import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.Collection;
 import org.apache.sis.util.Debug;
 import org.apache.sis.util.collection.TreeTable;
+import org.apache.sis.storage.DataStoreContentException;
 
 
 /**
  * Description of a database schema.
+ * Each schema contains a collection of {@link Table}s.
  *
  * @author  Johann Sorel (Geomatys)
+ * @author  Martin Desruisseaux (Geomatys)
  * @version 1.0
  * @since   1.0
  * @module
  */
 final class Schema extends MetaModel {
     /**
-     * The tables in the schema.
+     * The tables in this schema.
      */
-    final Map<String,Table> tables;
+    private final Map<String,Table> tables;
 
     /**
-     * Creates a new schema of the given name.
-     * It is caller responsibility to populate the {@link #tables} map.
+     * Creates a new, initially empty, schema of the given name.
+     *
+     * @param  schemaName  name of this schema.
      */
-    Schema(final String name) {
-        super(name);
-        tables = new HashMap<>();
+    Schema(final String schemaName) {
+        super(schemaName);
+        tables = new LinkedHashMap<>();
     }
 
     /**
-     * Returns all tables in this schema.
+     * Adds a table in this schema.
+     *
+     * @param  table  the table to add.
+     * @throws DataStoreContentException if a table of the same name has already been added.
      */
-    Collection<Table> getTables() {
-        return tables.values();
+    void addTable(final Table table) throws DataStoreContentException {
+        if (tables.putIfAbsent(table.name, table) != null) {
+            throw new DataStoreContentException(Resources.format(Resources.Keys.DuplicatedEntity_2, "Table", table.name));
+        }
     }
 
     /**
      * Returns the table of the given name, or {@code null} if none.
      */
-    Table getTable(final String name){
+    Table getTable(final String name) {
         return tables.get(name);
     }
 
     /**
-     * Creates a tree representation of this object for debugging purpose.
-     *
-     * @param  parent  the parent node where to add the tree representation.
+     * Returns all tables in this schema.
+     */
+    Collection<Table> getTables() {
+        return tables.values();
+    }
+
+    /**
+     * Creates a tree representation of this schema with the list of all tables.
      */
     @Debug
     @Override
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/SpatialFunctions.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/SpatialFunctions.java
index e53966b..af3fbb5 100644
--- a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/SpatialFunctions.java
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/SpatialFunctions.java
@@ -41,8 +41,8 @@ import org.apache.sis.storage.DataStoreException;
  */
 abstract class SpatialFunctions {
     /**
-     * The tables to be ignored when inspecting the tables in a database schema.
-     * Those tables are used for database (e.g. PostGIS) internal working.
+     * Names of tables to ignore when inspecting a database schema.
+     * Those tables are used for database internal working (for example by PostGIS).
      */
     private final Set<String> ignoredTables;
 
@@ -74,7 +74,7 @@ abstract class SpatialFunctions {
     }
 
     /**
-     * Indicates whether a table is reserved for the database internal working.
+     * Returns whether a table is reserved for database internal working.
      * If this method returns {@code false}, then the given table is a candidate
      * for use as a {@code FeatureType}.
      *
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Table.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Table.java
index ecdf264..d451f48 100644
--- a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Table.java
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/Table.java
@@ -24,7 +24,7 @@ import org.apache.sis.feature.builder.FeatureTypeBuilder;
 
 
 /**
- * Description of a database table.
+ * Description of a table in the database. The description is provided as a {@code FeatureType}.
  *
  * @author  Johann Sorel (Geomatys)
  * @version 1.0
@@ -32,54 +32,53 @@ import org.apache.sis.feature.builder.FeatureTypeBuilder;
  * @module
  */
 final class Table extends MetaModel {
-    enum View {
-        TABLE,
-        SIMPLE_FEATURE_TYPE,
-        COMPLEX_FEATURE_TYPE,
-        ALL_COMPLEX
-    }
-
-    String type;
-
+    /**
+     * @deprecated to be replaced by {@link #featureType} only (TODO).
+     */
+    @Deprecated
     FeatureTypeBuilder tableType;
-    FeatureTypeBuilder simpleFeatureType;
-    FeatureTypeBuilder complexFeatureType;
-    FeatureTypeBuilder allTypes;
 
-    PrimaryKey key;
+    /**
+     * A temporary object used for building the {@code FeatureType}.
+     */
+    FeatureTypeBuilder featureType;
 
     /**
-     * those are 0:1 relations
+     * The primary key of this table.
      */
-    final Collection<Relation> importedKeys = new ArrayList<>();
+    PrimaryKey key;
 
     /**
-     * those are 0:N relations
+     * The primary keys of other tables that are referenced by this table foreign key columns.
+     * They are 0:1 relations.
      */
-    final Collection<Relation> exportedKeys = new ArrayList<>();
+    final Collection<Relation> importedKeys;
 
     /**
-     * inherited tables
+     * The foreign keys of other tables that reference this table primary key columns.
+     * They are 0:N relations
      */
-    final Collection<String> parents = new ArrayList<>();
+    final Collection<Relation> exportedKeys;
 
-    Table(final String name, final String type) {
+    /**
+     * Creates a new table of the given name.
+     */
+    Table(final String name) {
         super(name);
-        this.type = type;
+        importedKeys = new ArrayList<>();
+        exportedKeys = new ArrayList<>();
     }
 
     /**
-     * Determines if given type is a subtype. Conditions are:
+     * Determines if this table is a component of another table. Conditions are:
      * <ul>
      *   <li>having a relation toward another type</li>
      *   <li>relation must be cascading.</li>
      * </ul>
      *
-     * @return true is type is a subtype.
-     *
-     * @todo a subtype of what?
+     * @return whether this table is a component of another table.
      */
-    boolean isSubType() {
+    boolean isComponent() {
         for (Relation relation : importedKeys) {
             if (relation.cascadeOnDelete) {
                 return true;
@@ -88,16 +87,6 @@ final class Table extends MetaModel {
         return false;
     }
 
-    FeatureTypeBuilder getType(final View view) {
-        switch (view) {
-            case TABLE:                return tableType;
-            case SIMPLE_FEATURE_TYPE:  return simpleFeatureType;
-            case COMPLEX_FEATURE_TYPE: return complexFeatureType;
-            case ALL_COMPLEX:          return allTypes;
-            default: throw new IllegalArgumentException("Unknown view type: " + view);
-        }
-    }
-
     /**
      * Creates a tree representation of this object for debugging purpose.
      *
diff --git a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/package-info.java b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/package-info.java
index 80a2211..e123306 100644
--- a/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/package-info.java
+++ b/storage/sis-sql/src/main/java/org/apache/sis/internal/sql/feature/package-info.java
@@ -17,7 +17,7 @@
 
 
 /**
- * Build {@link org.opengis.feature.FeatureType}s by inspection of a database schema.
+ * Build {@link org.opengis.feature.FeatureType}s by inspection of database schemas.
  * The work done here is similar to reverse engineering.
  *
  * <STRONG>Do not use!</STRONG>


Mime
View raw message