/*
* Geotoolkit.org - An Open Source Java GIS Toolkit
* http://www.geotoolkit.org
*
* (C) 2005-2012, Open Source Geospatial Foundation (OSGeo)
* (C) 2007-2012, Geomatys
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotoolkit.internal.sql.table;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLDataException;
import java.sql.PreparedStatement;
import java.util.Set;
import java.util.LinkedHashSet;
import org.apache.sis.util.logging.Logging;
import org.apache.sis.util.collection.Cache;
import org.geotoolkit.internal.sql.TypeMapper;
import org.geotoolkit.resources.Errors;
/**
* Base class for tables with a {@code getEntry(...)} method returning at most one entry.
* The entries are uniquely identified by an identifier, which may be a string or an integer.
* <p>
* {@code SingletonTable} defines the {@link #getEntries()}, {@link #getEntry(String)} and
* {@link #getEntry(int)} methods. Subclasses shall provide implementation for the following
* methods:
* <p>
* <ul>
* <li>{@link #configure(QueryType, PreparedStatement)} (optional)</li>
* <li>{@link #createEntry(ResultSet)}: Creates an entry for the current row.</li>
* </ul>
* <p>
* The entries created by this class are cached for faster access the next time a
* {@code getEntry(...)} method is invoked again.
*
* @param <E> The kind of entries to be created by this table.
*
* @author Martin Desruisseaux (IRD, Geomatys)
* @version 3.15
*
* @since 3.09 (derived from Seagis)
* @module
*/
public abstract class SingletonTable<E extends Entry> extends Table {
/**
* The main parameters to use for the identification of an entry, or an empty array if none.
*/
private final Parameter[] pkParam;
/**
* The entries created up to date. The keys shall be {@link Integer}, {@link String} or
* {@link MultiColumnsIdentifier} instances only. Note that this field is shared between
* different {@code Table} instances of the same kind created for the same database.
*/
private final Cache<Comparable<?>,E> cache;
/**
* A generator of {@link String} identifiers, created when first needed.
*/
private transient NameGenerator generator;
/**
* Creates a new table using the specified query. The optional {@code pkParam} argument
* defines the parameters to use for looking an element by identifier. This is usually the
* parameter for the value to search in the primary key column. This information is needed
* for {@link #getEntry(String)} execution.
*
* @param query The query to use for this table.
* @param pkParam The parameters for looking an element by name.
* @throws IllegalArgumentException if the specified parameters are not one of those
* declared for {@link QueryType#SELECT}.
*/
protected SingletonTable(final Query query, final Parameter... pkParam) {
super(query);
this.pkParam = pkParam.clone();
cache = new Cache<>();
}
/**
* Creates a new table connected to the same {@linkplain #getDatabase database} and using
* the same {@linkplain #query query} than the specified table. Subclass constructors should
* not modify the query, since it is shared.
* <p>
* This constructor shares also the cache. This is okay if the entries created by the
* table does not depend on the table configuration.
*
* @param table The table to use as a template.
*/
protected SingletonTable(final SingletonTable<E> table) {
super(table);
pkParam = table.pkParam;
cache = table.cache;
}
/**
* Returns the 1-based column indices of the primary keys. Note that some elements in the
* returned array may be 0 if the corresponding parameter is not applicable to the current
* query type.
* <p>
* This method infers the "<cite>primary keys</cite>" from the {@code pkParam} argument
* given to the constructor. This is usually the primary key defined in the database,
* but this is not verified.
*
* @return The indices of the primary key columns.
*/
private int[] getPrimaryKeyColumns() {
final QueryType type = getQueryType();
final int[] indices = new int[pkParam.length];
for (int i=0; i<indices.length; i++) {
indices[i] = pkParam[i].column.indexOf(type);
}
return indices;
}
/**
* Returns the first value of {@link #getPrimaryKeyColumns()} which is different than 0,
* or 0 if none. This is a convenience method used only for formatting exception messages.
*
* @return The index of the first primary key column, or 0 if none.
*/
private int getPrimaryKeyColumn() {
return getPrimaryKeyColumn(getPrimaryKeyColumns());
}
/**
* Returns the first value of the given array which is different than zero.
* If none is found, returns zero.
*/
private static int getPrimaryKeyColumn(final int[] pkIndices) {
for (final int column : pkIndices) {
if (column != 0) {
return column;
}
}
return 0;
}
/**
* Sets the value of the parameters associated to the primary key columns.
*
* @param statement The statement in which to set the parameter value.
* @param identifier The identifier to set in the statement.
* @throws SQLException If the parameter can not be set.
*/
private void setPrimaryKeyParameter(final PreparedStatement statement, final Comparable<?> identifier)
throws SQLException
{
final Comparable<?>[] identifiers;
if (identifier instanceof MultiColumnIdentifier<?>) {
identifiers = ((MultiColumnIdentifier<?>) identifier).getIdentifiers();
} else {
identifiers = new Comparable<?>[] {identifier};
}
if (identifiers.length != pkParam.length) {
throw new CatalogException(errors().getString(Errors.Keys.MismatchedArrayLength));
}
for (int i=0; i<identifiers.length; i++) {
final Comparable<?> id = identifiers[i];
final int pkIndex = indexOf(pkParam[i]);
if (id instanceof Number) {
statement.setInt(pkIndex, ((Number) id).intValue());
} else {
statement.setString(pkIndex, id.toString());
}
}
}
/**
* Returns {@code true} if the given column in the result set is numeric.
*
* @param results The result set.
* @param pkIndex The index of the column to inspect (typically the primary key), or 0 if none.
* @return {@code true} If the given column in the given result set is numeric.
* @throws SQLException If an error occurred while fetching the metadata.
*/
private static boolean isNumeric(final ResultSet results, final int pkIndex) throws SQLException {
if (pkIndex != 0) {
final Class<?> type = TypeMapper.toJavaType(results.getMetaData().getColumnType(pkIndex));
if (type != null) {
return Number.class.isAssignableFrom(type);
}
}
return false;
}
/**
* Returns {@code true} if the prepared statement to be created by
* {@link #getStatement(String)} should be able to return auto-generated keys.
*/
@Override
final boolean wantsAutoGeneratedKeys() {
return getQueryType() == QueryType.INSERT;
}
/**
* Creates an identifier for the current row in the given result set. This method needs to
* be overridden by subclasses using {@link MultiColumnIdentifier}. Other subclasses don't
* need to override this method: a {@link String} or {@link Integer} identifier will be
* used as needed.
* <p>
* This method is invoked by {@link #getEntries()} only. It should not be invoked otherwise.
*
* @param results The result set.
* @param pkIndices The indices of the column to inspect (typically the primary keys).
* @return The {@linkplain MultiColumnIdentifier multi-column identifier}.
* @throws SQLException If an error occurred while fetching the data.
*
* @since 3.10
*/
protected Comparable<?> createIdentifier(ResultSet results, int[] pkIndices) throws SQLException {
if (pkIndices.length == 1) {
return null; // Special value to be handled by getEntries().
}
throw new CatalogException(errors().getString(Errors.Keys.UnsupportedOperation_1, getQueryType()));
}
/**
* Creates an {@link Element} object for the current {@linkplain ResultSet result set} row.
* This method is invoked automatically by {@link #getEntry(String)} and {@link #getEntries()}.
*
* @param lc The {@link #getLocalCache()} value.
* @param results The result set to use for fetching data. Only the current row should
* be used, i.e. {@link ResultSet#next} should <strong>not</strong> be invoked.
* @param identifier The identifier of the entry being created.
* @return The element for the current row in the specified {@code results}.
* @throws CatalogException if a logical error has been detected in the database content.
* @throws SQLException if an error occurred will reading from the database.
*/
protected abstract E createEntry(final LocalCache lc, final ResultSet results, final Comparable<?> identifier)
throws CatalogException, SQLException;
/**
* Invokes the user's {@link #createEntry(ResultSet)} method, but wraps {@link SQLException}
* into {@link CatalogException} because the later provides more informations.
*
* @throws CatalogException If an error occurred during {@link #createEntry(ResultSet)}.
* @throws SQLException If an error occurred during {@link CatalogException#setMetadata}.
* Note that this is not an error occurring during normal execution, but rather
* an error occurring while querying database metadata for building the exception.
*/
private E createEntryCatchSQL(final LocalCache lc, final ResultSet results, final Comparable<?> identifier)
throws CatalogException, SQLException
{
CatalogException exception;
try {
return createEntry(lc, results, identifier);
} catch (CatalogException cause) {
if (cause.isMetadataInitialized()) {
throw cause;
}
exception = cause;
} catch (SQLException cause) {
exception = new CatalogException(cause);
}
exception.setMetadata(this, results, getPrimaryKeyColumn(), identifier);
exception.clearColumnName();
throw exception;
}
/**
* Returns an element for the given identifier.
*
* @param identifier The name or numeric identifier of the element to fetch.
* @return The element for the given identifier, or {@code null} if {@code identifier} was null.
* @throws NoSuchRecordException if no record was found for the specified key.
* @throws SQLException if an error occurred will reading from the database.
*/
public E getEntry(final Comparable<?> identifier) throws NoSuchRecordException, SQLException {
if (identifier == null) {
return null;
}
E entry = cache.peek(identifier);
if (entry == null) {
final Cache.Handler<E> handler = cache.lock(identifier);
try {
entry = handler.peek();
if (entry == null) {
final LocalCache lc = getLocalCache();
synchronized (lc) {
final LocalCache.Stmt ce = getStatement(lc, QueryType.SELECT);
final PreparedStatement statement = ce.statement;
setPrimaryKeyParameter(statement, identifier);
try (ResultSet results = statement.executeQuery()) {
while (results.next()) {
final E candidate = createEntryCatchSQL(lc, results, identifier);
if (entry == null) {
entry = candidate;
} else if (!entry.equals(candidate)) {
// The ResultSet will be closed by the constructor below.
throw new DuplicatedRecordException(this, results, getPrimaryKeyColumn(), identifier);
}
}
if (entry == null) {
// The ResultSet will be closed by the constructor below.
throw new NoSuchRecordException(this, results, getPrimaryKeyColumn(), identifier);
}
}
release(lc, ce);
}
}
} finally {
handler.putAndUnlock(entry);
}
}
return entry;
}
/**
* Returns all entries available in the database.
*
* @return The set of entries. May be empty, but never {@code null}.
* @throws SQLException if an error occurred will reading from the database.
*/
public Set<E> getEntries() throws SQLException {
final Set<E> entries = new LinkedHashSet<>();
final LocalCache lc = getLocalCache();
synchronized (lc) {
final LocalCache.Stmt ce;
try {
ce = getStatement(lc, QueryType.LIST);
} catch (SQLDataException e) {
/*
* This happen if BoundedSingletonTable has been given an envelope filled with NaN
* values, for example because it has been projected from a different CRS far from
* the domain of validity. We handle such envelope as an out-of-bounds envelope,
* so there is no record that intersect the requested area.
*/
Logging.recoverableException(getLogger(), getClass(), "getEntries", e);
return entries;
}
final int[] pkIndices = getPrimaryKeyColumns();
final int pkIndex = getPrimaryKeyColumn(pkIndices);
try (ResultSet results = ce.statement.executeQuery()) {
Boolean isNumeric = null;
while (results.next()) {
Comparable<?> identifier = createIdentifier(results, pkIndices);
if (identifier == null) {
if (isNumeric == null) {
isNumeric = isNumeric(results, pkIndex);
}
if (isNumeric) {
identifier = results.getInt(pkIndex);
} else {
identifier = results.getString(pkIndex);
}
}
E entry = cache.peek(identifier);
if (entry == null) {
final Cache.Handler<E> handler = cache.lock(identifier);
try {
entry = handler.peek();
if (entry == null) {
entry = createEntryCatchSQL(lc, results, identifier);
}
} finally {
handler.putAndUnlock(entry);
}
}
if (!entries.add(entry)) {
// The ResultSet will be closed by the constructor below.
throw new DuplicatedRecordException(this, results, pkIndex, identifier);
}
}
}
release(lc, ce); // Push back to the pool only on success.
}
return entries;
}
/**
* Returns the names of all entries available in the database. This method is much
* more economical than {@link #getEntries()} when only the identifiers are wanted.
*
* @return The set of entry identifiers. May be empty, but never {@code null}.
* @throws SQLException if an error occurred will reading from the database.
*/
public Set<String> getIdentifiers() throws SQLException {
final Set<String> identifiers = new LinkedHashSet<>();
final LocalCache lc = getLocalCache();
synchronized (lc) {
final LocalCache.Stmt ce = getStatement(lc, QueryType.LIST_ID);
try (ResultSet results = ce.statement.executeQuery()) {
final int index = getPrimaryKeyColumn();
while (results.next()) {
identifiers.add(results.getString(index));
}
}
release(lc, ce);
}
return identifiers;
}
/**
* Checks if an entry exists for the given name. This method does not attempt to create
* the entry and doesn't check if the entry is valid.
*
* @param identifier The identifier of the entry to fetch.
* @return {@code true} if an entry of the given identifier was found.
* @throws SQLException if an error occurred will reading from the database.
*/
public boolean exists(final Comparable<?> identifier) throws SQLException {
if (identifier == null) {
return false;
}
if (cache.containsKey(identifier)) {
return true;
}
final boolean hasNext;
final LocalCache lc = getLocalCache();
synchronized (lc) {
final LocalCache.Stmt ce = getStatement(lc, QueryType.EXISTS);
final PreparedStatement statement = ce.statement;
setPrimaryKeyParameter(statement, identifier);
try (ResultSet results = statement.executeQuery()) {
hasNext = results.next();
}
release(lc, ce);
}
return hasNext;
}
/**
* Deletes the entry for the given identifier.
*
* @param identifier The identifier of the entry to delete.
* @return The number of entries deleted.
* @throws SQLException if an error occurred will reading from or writing to the database.
*/
public int delete(final Comparable<?> identifier) throws SQLException {
if (identifier == null) {
return 0;
}
final int count;
boolean success = false;
final LocalCache lc = getLocalCache();
synchronized (lc) {
transactionBegin(lc);
try {
final LocalCache.Stmt ce = getStatement(lc, QueryType.DELETE);
final PreparedStatement statement = ce.statement;
setPrimaryKeyParameter(statement, identifier);
count = update(statement);
release(lc, ce);
success = true;
} finally {
transactionEnd(lc, success);
}
}
// Update the cache only on successfuly deletion.
cache.remove(identifier);
return count;
}
/**
* Deletes many elements. "Many" depends on the configuration set by {@link #configure}.
* It may be the whole table. Note that this action may be blocked if the user doesn't
* have the required database authorizations, or if some records are still referenced in
* foreigner tables.
*
* @return The number of elements deleted.
* @throws SQLException if an error occurred will reading from or writing to the database.
*/
public int deleteAll() throws SQLException {
final int count;
boolean success = false;
final LocalCache lc = getLocalCache();
synchronized (lc) {
transactionBegin(lc);
try {
final LocalCache.Stmt ce = getStatement(lc, QueryType.DELETE_ALL);
count = update(ce.statement);
release(lc, ce);
success = true;
} finally {
transactionEnd(lc, success);
}
}
// Update the cache only on successfuly deletion.
cache.clear();
return count;
}
/**
* Executes the specified SQL {@code INSERT}, {@code UPDATE} or {@code DELETE} statement.
*
* @param statement The statement to execute.
* @return The number of elements updated.
* @throws SQLException if an error occurred.
*/
private int update(final PreparedStatement statement) throws SQLException {
final Database database = getDatabase();
database.ensureOngoingTransaction();
return statement.executeUpdate();
}
/**
* Executes the specified SQL {@code INSERT}, {@code UPDATE} or {@code DELETE} statement,
* which is expected to insert exactly one record. As a special case, this method does not
* execute the statement during testing and debugging phases. In the later case, this method
* rather prints the statement to the stream specified to {@link Database#setUpdateSimulator}.
*
* @param statement The statement to execute.
* @return {@code true} if the singleton has been found and updated.
* @throws IllegalUpdateException if more than one elements has been updated.
* @throws SQLException if an error occurred.
*/
protected final boolean updateSingleton(final PreparedStatement statement)
throws IllegalUpdateException, SQLException
{
final int count = update(statement);
if (count > 1) {
throw new IllegalUpdateException(getLocale(), count);
}
return count != 0;
}
/**
* Searches for an identifier not already in use. If the given string is not in use, then
* it is returned as-is. Otherwise, this method appends a decimal number to the specified
* base and check if the resulting identifier is not in use. If it is, then the decimal
* number is incremented until a unused identifier is found.
* <p>
* This method is suitable for {@link String} identifiers. Numerical identifiers shall
* use an auto-increment field instead.
*
* @param lc The value returned by {@link #getLocalCache()}.
* @param base The base for the identifier.
* @return A unused identifier.
* @throws SQLException if an error occurred while reading the database.
*
* @since 3.11
*/
protected final String searchFreeIdentifier(final LocalCache lc, final String base) throws SQLException {
if (generator == null) {
if (pkParam.length == 0) {
throw new UnsupportedOperationException();
}
generator = getDatabase().getIdentifierGenerator(lc, pkParam[0].column.name);
}
return generator.identifier(query.schema, query.table, base);
}
}