/*
 * 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.Map;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Optional;
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;
import org.apache.sis.feature.builder.AttributeTypeBuilder;
import org.apache.sis.feature.builder.AssociationRoleBuilder;
import org.apache.sis.feature.builder.FeatureTypeBuilder;
import org.apache.sis.internal.feature.Geometries;
import org.apache.sis.storage.DataStoreException;
import org.apache.sis.storage.DataStoreContentException;
import org.apache.sis.storage.InternalDataStoreException;
import org.apache.sis.internal.metadata.sql.Reflection;
import org.apache.sis.internal.metadata.sql.SQLUtilities;
import org.apache.sis.internal.storage.AbstractFeatureSet;
import org.apache.sis.internal.util.CollectionsExt;
import org.apache.sis.util.collection.WeakValueHashMap;
import org.apache.sis.util.collection.TreeTable;
import org.apache.sis.util.CharSequences;
import org.apache.sis.util.Exceptions;
import org.apache.sis.util.Classes;
import org.apache.sis.util.Numbers;
import org.apache.sis.util.Debug;

// Branch-dependent imports
import org.apache.sis.feature.AbstractFeature;
import org.apache.sis.feature.DefaultFeatureType;
import org.apache.sis.feature.DefaultAssociationRole;


/**
 * Description of a table in the database, including columns, primary keys and foreigner keys.
 * This class contains a {@code FeatureType} inferred from the table structure. The {@code FeatureType}
 * contains an {@code AttributeType} for each table column, except foreigner keys which are represented
 * by {@link DefaultAssociationRole}s.
 *
 * @author  Johann Sorel (Geomatys)
 * @author  Martin Desruisseaux (Geomatys)
 * @version 1.0
 * @since   1.0
 * @module
 */
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.
     */
    final DefaultFeatureType featureType;

    /**
     * The name in the database of this {@code Table} object, together with its schema and catalog.
     */
    final TableReference name;

    /**
     * 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 columns that constitute the primary key, or {@code null} if there is no primary key.
     */
    private final String[] primaryKeys;

    /**
     * The primary keys of other tables that are referenced by this table foreign key columns.
     * They are 0:1 relations. May be {@code null} if there is no imported keys.
     */
    private final Relation[] importedKeys;

    /**
     * The foreign keys of other tables that reference this table primary key columns.
     * They are 0:N relations. May be {@code null} if there is no exported keys.
     */
    private final Relation[] exportedKeys;

    /**
     * The class of primary key values, or {@code null} if there is no primary keys.
     * If the primary keys use more than one column, then this field is the class of
     * an array; it may be an array of primitive type.
     */
    final Class<?> primaryKeyClass;

    /**
     * Feature instances already created for given primary keys. This map is used only when requesting feature
     * instances by identifiers (not for iterating over all features) and those identifiers are primary keys.
     * We create this map only for tables referenced by foreigner keys of other tables as enumerated by the
     * {@link Relation.Direction#IMPORT} and {@link Relation.Direction#EXPORT} cases; not for arbitrary
     * cross-reference cases. Values are usually {@code Feature} instances, but may also be {@code Collection<Feature>}.
     *
     * @see #instanceForPrimaryKeys()
     */
    private WeakValueHashMap<?,Object> instanceForPrimaryKeys;

    /**
     * {@code true} if this table contains at least one geometry column.
     */
    final boolean hasGeometry;

    /**
     * Creates a description of the table of the given name.
     * The table is identified by {@code id}, which contains a (catalog, schema, name) tuple.
     * The catalog and schema parts are optional and can be null, but the table is mandatory.
     *
     * @param  analyzer    helper functions, e.g. for converting SQL types to Java types.
     * @param  id          the catalog, schema and table name of the table to analyze.
     * @param  importedBy  if this table is imported by the foreigner keys of another table,
     *                     the parent table. Otherwise {@code null}.
     */
    Table(final Analyzer analyzer, final TableReference id, final TableReference importedBy)
            throws SQLException, DataStoreException
    {
        super(analyzer.listeners);
        this.source = analyzer.source;
        this.name   = id;
        final String tableEsc  = analyzer.escape(id.table);
        final String schemaEsc = analyzer.escape(id.schema);
        /*
         * Get a list of primary keys. We need to know them before to create the attributes,
         * in order to detect which attributes are used as components of Feature identifiers.
         * In the 'primaryKeys' map, the boolean tells whether the column uses auto-increment,
         * with null value meaning that we don't know.
         *
         * Note: when a table contains no primary keys, we could still look for index columns
         * with unique constraint using metadata.getIndexInfo(catalog, schema, table, true).
         * 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 LinkedHashMap<>();
        try (ResultSet reflect = analyzer.metadata.getPrimaryKeys(id.catalog, id.schema, id.table)) {
            while (reflect.next()) {
                primaryKeys.put(analyzer.getUniqueString(reflect, Reflection.COLUMN_NAME), null);
                // The actual Boolean value will be fetched in the loop on columns later.
            }
        }
        this.primaryKeys = primaryKeys.isEmpty() ? null : primaryKeys.keySet().toArray(new String[primaryKeys.size()]);
        /*
         * 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.
         */
        final List<Relation> importedKeys = 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 {
                Relation relation = new Relation(analyzer, 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());
        }
        final List<Relation> exportedKeys = new ArrayList<>();
        try (ResultSet reflect = analyzer.metadata.getExportedKeys(id.catalog, id.schema, id.table)) {
            if (reflect.next()) do {
                final Relation export = new Relation(analyzer, Relation.Direction.EXPORT, reflect);
                if (!export.equals(importedBy)) {
                    exportedKeys.add(export);
                }
            } while (!reflect.isClosed());
        }
        /*
         * For each column in the table that is not a foreigner key, create an AttributeType of the same name.
         * The Java type is inferred from the SQL type, and the attribute multiplicity in inferred from the SQL
         * 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.
         */
        Class<?> primaryKeyClass   = null;
        boolean  primaryKeyNonNull = true;
        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()) {
                final String         column       = analyzer.getUniqueString(reflect, Reflection.COLUMN_NAME);
                final boolean        mandatory    = Boolean.FALSE.equals(SQLUtilities.parseBoolean(reflect.getString(Reflection.IS_NULLABLE)));
                final boolean        isPrimaryKey = primaryKeys.containsKey(column);
                final List<Relation> dependencies = foreignerKeys.get(column);
                /*
                 * Heuristic rule for determining if the column names starts with lower case or upper case.
                 * Words that are all upper-case are ignored on the assumption that they are acronyms.
                 */
                if (!column.isEmpty()) {
                    final int firstLetter = column.codePointAt(0);
                    if (Character.isLowerCase(firstLetter)) {
                        startWithLowerCase++;
                    } else if (Character.isUpperCase(firstLetter) && !CharSequences.isUpperCase(column)) {
                        startWithLowerCase--;
                    }
                }
                /*
                 * Add the column as an attribute. Foreign keys are excluded (they will be replaced by associations),
                 * except if the column is also a primary key. In the later case we need to keep that column because
                 * it is needed for building the feature identifier.
                 */
                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) {
                        analyzer.warning(Resources.Keys.UnknownType_1, typeName);
                        type = Object.class;
                    }
                    attribute = feature.addAttribute(type).setName(column);
                    if (CharSequence.class.isAssignableFrom(type)) {
                        final int size = reflect.getInt(Reflection.COLUMN_SIZE);
                        if (!reflect.wasNull()) {
                            attribute.setMaximalLength(size);
                        }
                    }
                    if (!mandatory) {
                        attribute.setMinimumOccurs(0);
                    }
                    /*
                     * Some columns have special purposes: components of primary keys will be used for creating
                     * identifiers, some columns may contain a geometric object. Adding a role on those columns
                     * may create synthetic columns, for example "sis:identifier".
                     */
                    if (isPrimaryKey) {
                        attribute.addRole(AttributeRole.IDENTIFIER_COMPONENT);
                        primaryKeyNonNull &= mandatory;
                        primaryKeyClass = Classes.findCommonClass(primaryKeyClass, type);
                        if (primaryKeys.put(column, SQLUtilities.parseBoolean(reflect.getString(Reflection.IS_AUTOINCREMENT))) != null) {
                            throw new DataStoreContentException(Resources.forLocale(analyzer.locale)
                                    .getString(Resources.Keys.DuplicatedColumn_1, column));
                        }
                    }
                    if (Geometries.isKnownType(type)) {
                        final CoordinateReferenceSystem crs = analyzer.functions.createGeometryCRS(reflect);
                        if (crs != null) {
                            attribute.setCRS(crs);
                        }
                        if (!hasGeometry) {
                            hasGeometry = true;
                            attribute.addRole(AttributeRole.DEFAULT_GEOMETRY);
                        }
                    }
                }
                /*
                 * If the column is a foreigner key, insert an association to another feature instead.
                 * If the foreigner key uses more than one column, only one of those columns will become
                 * an association and other columns will be omitted from the FeatureType (but there will
                 * still be used in SQL queries). Note that columns may be used by more than one relation.
                 */
                if (dependencies != null) {
                    int count = 0;
                    for (final Relation dependency : dependencies) {
                        if (dependency != null) {
                            final GenericName typeName = dependency.getName(analyzer);
                            final Table table = analyzer.table(dependency, typeName, id);
                            /*
                             * Use the column name as the association name, provided that the foreigner key
                             * use only that column. If the foreigner key use more than one column, then we
                             * do not know which column describes better the association (often there is none).
                             * In such case we use the foreigner key name as a fallback.
                             */
                            dependency.setPropertyName(column, count++);
                            final AssociationRoleBuilder association;
                            if (table != null) {
                                dependency.setSearchTable(analyzer, table, table.primaryKeys, Relation.Direction.IMPORT);
                                association = feature.addAssociation(table.featureType);
                            } else {
                                association = feature.addAssociation(typeName);     // May happen in case of cyclic dependency.
                            }
                            association.setName(dependency.propertyName);
                            if (!mandatory) {
                                association.setMinimumOccurs(0);
                            }
                            /*
                             * If the column is also used in the primary key, then we have a name clash.
                             * Rename the primary key column with the addition of a "pk:" scope. We rename
                             * the primary key column instead than this association because the primary key
                             * column should rarely be used directly.
                             */
                            if (attribute != null) {
                                attribute.setName(analyzer.nameFactory.createGenericName(null, "pk", column));
                                attributeNames.set(attributeNames.size() - 1, attribute.getName().toString());
                                attribute = null;
                            }
                        }
                    }
                }
            }
        }
        /*
         * Add the associations created by other tables having foreigner keys to this table.
         * We infer the column name from the target type. We may have a name clash with other
         * columns, in which case an arbitrary name change is applied.
         */
        int count = 0;
        for (final Relation dependency : exportedKeys) {
            if (dependency != null) {
                final GenericName typeName = dependency.getName(analyzer);
                String propertyName = typeName.tip().toString();
                if (startWithLowerCase > 0) {
                    final CharSequence words = CharSequences.camelCaseToWords(propertyName, true);
                    final int first = Character.codePointAt(words, 0);
                    propertyName = new StringBuilder(words.length())
                            .appendCodePoint(Character.toLowerCase(first))
                            .append(words, Character.charCount(first), words.length())
                            .toString();
                }
                final String base = propertyName;
                while (feature.isNameUsed(propertyName)) {
                    propertyName = base + '-' + ++count;
                }
                dependency.propertyName = propertyName;
                final Table table = analyzer.table(dependency, typeName, null);   // 'null' because exported, not imported.
                final AssociationRoleBuilder association;
                if (table != null) {
                    dependency.setSearchTable(analyzer, table, this.primaryKeys, Relation.Direction.EXPORT);
                    association = feature.addAssociation(table.featureType);
                } else {
                    association = feature.addAssociation(typeName);     // May happen in case of cyclic dependency.
                }
                association.setName(propertyName)
                           .setMinimumOccurs(0)
                           .setMaximumOccurs(Integer.MAX_VALUE);
            }
        }
        /*
         * If the primary keys uses more than one column, we will need an array to store it.
         * If all columns are non-null numbers, use primitive arrays instead than array of wrappers.
         */
        if (primaryKeys.size() > 1) {
            if (primaryKeyNonNull) {
                primaryKeyClass = Numbers.wrapperToPrimitive(primaryKeyClass);
            }
            primaryKeyClass = Classes.changeArrayDimension(primaryKeyClass, 1);
        }
        /*
         * Global information on the feature type (name, remarks).
         * The remarks are opportunistically stored in id.freeText if known by the caller.
         */
        feature.setName(id.getName(analyzer));
        String remarks = id.freeText;
        if (id instanceof Relation) {
            try (ResultSet reflect = analyzer.metadata.getTables(id.catalog, schemaEsc, tableEsc, null)) {
                while (reflect.next()) {
                    remarks = analyzer.getUniqueString(reflect, Reflection.REMARKS);
                    if (remarks != null) {
                        remarks = remarks.trim();
                        if (remarks.isEmpty()) {
                            remarks = null;
                        } else break;
                    }
                }
            }
        }
        if (remarks != null) {
            feature.setDefinition(remarks);
        }
        this.featureType      = feature.build();
        this.importedKeys     = toArray(importedKeys);
        this.exportedKeys     = toArray(exportedKeys);
        this.primaryKeyClass  = primaryKeyClass;
        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;
    }

    /**
     * Sets the search tables on all {@link Relation} instances for which this operation has been deferred.
     * This happen when a table could not be obtained because of circular dependency. This method is invoked
     * after all tables have been created in order to fill such holes.
     *
     * @param  tables  all tables created.
     */
    final void setDeferredSearchTables(final Analyzer analyzer, final Map<GenericName,Table> tables) throws DataStoreException {
        for (final Relation.Direction direction : Relation.Direction.values()) {
            final Relation[] relations;
            switch (direction) {
                case IMPORT: relations = importedKeys; break;
                case EXPORT: relations = exportedKeys; break;
                default: continue;
            }
            if (relations != null) {
                for (final Relation relation : relations) {
                    if (!relation.isSearchTableDefined()) {
                        // A ClassCastException below would be a bug since 'relation.propertyName' shall be for an association.
                        DefaultAssociationRole association = (DefaultAssociationRole) featureType.getProperty(relation.propertyName);
                        final Table table = tables.get(association.getValueType().getName());
                        if (table == null) {
                            throw new InternalDataStoreException(association.toString());
                        }
                        final String[] referenced;
                        switch (direction) {
                            case IMPORT: referenced = table.primaryKeys; break;
                            case EXPORT: referenced =  this.primaryKeys; break;
                            default: throw new AssertionError(direction);
                        }
                        relation.setSearchTable(analyzer, table, referenced, direction);
                    }
                }
            }
        }
    }


    // ────────────────────────────────────────────────────────────────────────────────────────
    //     End of table construction. Next methods are for visualizing the table structure.
    // ────────────────────────────────────────────────────────────────────────────────────────


    /**
     * Appends all children to the given parent. The children are added under the given node.
     * If the children array is null, then this method does nothing.
     *
     * @param  parent    the node where to add children.
     * @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 Relation[] children, final String arrow) {
        if (children != null) {
            for (final Relation child : children) {
                child.appendTo(parent, arrow);
            }
        }
    }

    /**
     * Creates a tree representation of this table for debugging purpose.
     *
     * @param  parent  the parent node where to add the tree representation.
     */
    @Debug
    final void appendTo(TreeTable.Node parent) {
        parent = Relation.newChild(parent, featureType.getName().toString());
        for (final String attribute : attributeNames) {
            TableReference.newChild(parent, attribute);
        }
        appendAll(parent, importedKeys, " → ");
        appendAll(parent, exportedKeys, " ← ");
    }

    /**
     * Formats a graphical representation of this table for debugging purpose. This representation
     * can be printed to the {@linkplain System#out standard output stream} (for example) if the
     * output device uses a monospaced font and supports Unicode.
     */
    @Override
    public String toString() {
        return TableReference.toString(this, (n) -> appendTo(n));
    }


    // ────────────────────────────────────────────────────────────────────────────────────────
    //     End of table structure visualization. Next methods are for fetching features.
    // ────────────────────────────────────────────────────────────────────────────────────────


    /**
     * Returns the table identifier composed of catalog, schema and table name.
     */
    @Override
    public final Optional<GenericName> getIdentifier() {
        return Optional.of(featureType.getName().toFullyQualifiedName());
    }

    /**
     * Returns the feature type inferred from the database structure analysis.
     */
    @Override
    public final DefaultFeatureType getType() {
        return featureType;
    }

    /**
     * If this table imports the inverse of the given relation, returns the imported relation.
     * Otherwise returns {@code null}. This method is used for preventing infinite recursivity.
     *
     * @param  exported       the relation exported by another table.
     * @param  exportedOwner  {@code exported.owner.name}: table that contains the {@code exported} relation.
     * @return the inverse of the given relation, or {@code null} if none.
     */
    final Relation getInverseOf(final Relation exported, final TableReference exportedOwner) {
        if (importedKeys != null && name.equals(exported)) {
            for (final Relation relation : importedKeys) {
                if (relation.equals(exportedOwner) && relation.isInverseOf(exported)) {
                    return relation;
                }
            }
        }
        return null;
    }

    /**
     * Returns a cache for fetching feature instances by identifier. The map is created when this method is
     * first invoked. Keys are primary key values, typically as {@code String} or {@code Integer} instances
     * or arrays of those if the keys use more than one column. Values are usually {@code Feature} instances,
     * but may also be {@code Collection<Feature>}.
     */
    @SuppressWarnings("ReturnOfCollectionOrArrayField")
    final synchronized WeakValueHashMap<?,Object> instanceForPrimaryKeys() {
        if (instanceForPrimaryKeys == null) {
            instanceForPrimaryKeys = new WeakValueHashMap<>(primaryKeyClass);
        }
        return instanceForPrimaryKeys;
    }

    /**
     * Returns the number of rows, or -1 if unknown. Note that some database drivers returns 0,
     * so it is better to consider 0 as "unknown" too. We do not cache this count because it may
     * change at any time.
     *
     * @param  metadata     information about the database.
     * @param  approximate  whether approximate or outdated values are acceptable.
     * @return number of rows (may be approximate), or -1 if unknown.
     */
    final long countRows(final DatabaseMetaData metadata, final boolean approximate) throws SQLException {
        long count = -1;
        final String[] names = TableReference.splitName(featureType.getName());
        try (ResultSet reflect = metadata.getIndexInfo(names[2], names[1], names[0], false, approximate)) {
            while (reflect.next()) {
                final long n = reflect.getLong(Reflection.CARDINALITY);
                if (!reflect.wasNull()) {
                    if (reflect.getShort(Reflection.TYPE) == DatabaseMetaData.tableIndexStatistic) {
                        return n;       // "Index statistic" type provides the number of rows in the table.
                    }
                    if (n > count) {    // Other index types may be inaccurate.
                        count = n;
                    }
                }
            }
        }
        return count;
    }

    /**
     * Returns a stream of all features contained in this dataset.
     *
     * @param  parallel  {@code true} for a parallel stream (if supported), or {@code false} for a sequential stream.
     * @return all features contained in this dataset.
     * @throws DataStoreException if an error occurred while creating the stream.
     */
    @Override
    public Stream<AbstractFeature> features(final boolean parallel) throws DataStoreException {
        DataStoreException ex;
        Connection connection = null;
        try {
            connection = source.getConnection();
            final Features iter = features(connection, new ArrayList<>(), 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.
     *
     * @param connection  connection to the database.
     * @param following   the relations that we are following. Used for avoiding never ending loop.
     * @param noFollow    relation to not follow, or {@code null} if none.
     */
    final Features features(final Connection connection, final List<Relation> following, final Relation noFollow)
            throws SQLException, InternalDataStoreException
    {
        return new Features(this, connection, attributeNames, attributeColumns, importedKeys, exportedKeys, following, noFollow);
    }
}
