/*
* Geotoolkit.org - An Open Source Java GIS Toolkit
* http://www.geotoolkit.org
*
* (C) 2004-2012, Open Source Geospatial Foundation (OSGeo)
* (C) 2009-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.metadata.sql;
import java.util.*;
import java.sql.Connection;
import java.sql.Statement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.SQLNonTransientException;
import javax.sql.DataSource;
import java.lang.reflect.Array;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.logging.Level;
import org.opengis.annotation.UML;
import org.opengis.util.CodeList;
import org.geotoolkit.resources.Errors;
import org.apache.sis.util.iso.Types;
import org.geotoolkit.internal.sql.SQLBuilder;
import org.geotoolkit.internal.sql.StatementPool;
import org.geotoolkit.internal.sql.DefaultDataSource;
import org.geotoolkit.internal.sql.StatementEntry;
import org.apache.sis.metadata.ValueExistencePolicy;
import org.apache.sis.metadata.KeyNamePolicy;
import org.apache.sis.metadata.MetadataStandard;
import org.apache.sis.util.collection.WeakValueHashMap;
import org.apache.sis.util.Classes;
import org.apache.sis.util.ObjectConverter;
import org.apache.sis.util.ObjectConverters;
import org.apache.sis.util.UnconvertibleObjectException;
import org.apache.sis.util.logging.Logging;
import static org.apache.sis.util.ArgumentChecks.ensureNonNull;
/**
* A connection to a metadata database in read-only mode. The database must have a schema
* of the given name ({@code "metadata"} in the example below). Existing entries can be
* obtained as in the example below:
*
* {@preformat java
* DataSource source = ... // This is database-specific.
* MetadataSource source = new MetadataSource(MetadataStandard.ISO_19115, source, "metadata");
* Telephone telephone = source.get(Telephone.class, id);
* }
*
* where {@code id} is the primary key value for the desired record in the {@code CI_Telephone} table.
*
* {@section Concurrency}
* {@code MetadataSource} is thread-safe but is not concurrent, because JDBC connections can not
* be assumed concurrent. If concurrency is desired, multiple instances of {@code MetadataSource}
* can be created for the same {@link DataSource}. The {@link #MetadataSource(MetadataSource)}
* convenience constructor can be used for this purpose.
*
* @author Touraïvane (IRD)
* @author Martin Desruisseaux (IRD, Geomatys)
* @version 3.03
*
* @since 3.03 (derived from 2.1)
* @module
*/
public class MetadataSource implements AutoCloseable {
/**
* The column name used for the identifiers. We do not quote this
* identifier; we will let the database uses its own convention.
*/
static final String ID_COLUMN = "ID";
/**
* The metadata standard to be stored in the database.
*/
protected final MetadataStandard standard;
/**
* The catalog, set to {@code null} for now. This is defined as a constant in order to
* make easier to spot the place where catalog would be used, if we want to use it in
* a future version.
*/
static final String CATALOG = null;
/**
* The schema where metadata are stored, or {@code null} if none.
*/
final String schema;
/**
* The tables which have been queried or created up to date. Keys are table names
* and values are the columns defined for that table.
*/
private final Map<String, Set<String>> tables;
/**
* The prepared statements created is previous call to {@link #getValue}.
* Those statements are encapsulated into {@link MetadataResult} objects.
* The key-value pairs must be one of the following:
* <p>
* <ul>
* <li>{@link Class} key with {@link MetadataResult} value</li>
* <li>{@link String} key with {@link StatementEntry} value</li>
* </ul>
* <p>
* This object is also the lock on which every SQL query must be garded.
* We use this object because SQL queries will typically involve usage of this map.
*/
final StatementPool<Object,StatementEntry> statements;
/**
* The previously created objects. Used in order to share existing instances
* for the same interface and primary key.
*/
private final WeakValueHashMap<CacheKey,Object> cache;
/**
* A buffer used for constructing SQL statements. This buffer is keep for the
* duration of this {@code MetadataSource} because it contains database metadata,
* so by keeping this helper object we avoid fetching those metadata every time.
*/
final SQLBuilder buffer;
/**
* The last converter used.
*/
private transient volatile ObjectConverter<?,?> lastConverter;
/**
* The class loader to use for proxy creation.
*/
private final ClassLoader loader;
/**
* Creates a new metadata source from the given JDBC URL. The URL must be conform to the
* syntax expected by the {@link java.sql.Driver#connect Driver.connect(...)} method,
* for example {@code "jdbc:postgresql://localhost/mydatabase"}.
* <p>
* This convenience method assumes that the metadata standard to be implemented is
* {@linkplain MetadataStandard#ISO_19115 ISO 19115}.
*
* @param url The URL to the JDBC database.
* @param schema The schema were metadata are expected to be found.
* @throws SQLException If the connection to the given database can not be established.
*/
public MetadataSource(final String url, final String schema) throws SQLException {
this(MetadataStandard.ISO_19115, new DefaultDataSource(url), schema);
}
/**
* Creates a new metadata source.
*
* @param standard The metadata standard to implement.
* @param dataSource The source for getting a connection to the database.
* @param schema The schema were metadata are expected to be found, or {@code null} if none.
* @throws SQLException If the connection to the given database can not be established.
*/
public MetadataSource(final MetadataStandard standard, final DataSource dataSource, final String schema)
throws SQLException
{
ensureNonNull("standard", standard);
ensureNonNull("dataSource", dataSource);
this.standard = standard;
this.schema = schema;
statements = new StatementPool<>(10, dataSource);
tables = new HashMap<>();
cache = new WeakValueHashMap<>(CacheKey.class);
loader = getClass().getClassLoader();
synchronized (statements) {
buffer = new SQLBuilder(statements.connection().getMetaData());
}
}
/**
* Creates a new metadata source with the same configuration than the given source.
* The two sources will share the same data source but will use their own
* {@linkplain Connection connection}.
*
* @param source The source from which to copy the configuration.
*/
public MetadataSource(final MetadataSource source) {
ensureNonNull("source", source);
standard = source.standard;
schema = source.schema;
loader = source.loader;
buffer = new SQLBuilder(source.buffer);
tables = new HashMap<>();
cache = new WeakValueHashMap<>(CacheKey.class);
statements = new StatementPool<>(source.statements);
}
/**
* If the given value is a collection, returns the first element in that collection
* or {@code null} if empty.
*
* @param value The value to inspect (can be {@code null}).
* @return The given value, or its first element if the value is a collection,
* or {@code null} if the given value is null or an empty collection.
*/
static Object extractFromCollection(Object value) {
while (value instanceof Iterable<?>) {
final Iterator<?> it = ((Iterable<?>) value).iterator();
if (!it.hasNext()) {
return null;
}
value = it.next();
}
return value;
}
/**
* Returns the table name for the specified class.
* This is usually the ISO 19115 name.
*/
static String getTableName(final Class<?> type) {
final UML annotation = type.getAnnotation(UML.class);
if (annotation == null) {
return type.getSimpleName();
}
final String name = annotation.identifier();
return name.substring(name.lastIndexOf('.') + 1);
}
/**
* Returns the column name for the specified method.
*/
private static String getColumnName(final Method method) {
final UML annotation = method.getAnnotation(UML.class);
if (annotation == null) {
return method.getName();
}
final String name = annotation.identifier();
return name.substring(name.lastIndexOf('.') + 1);
}
/**
* Returns a view of the given metadata as a map. This method returns always a map
* using UML identifier and containing all entries including the null ones because
* the {@code MetadataSource} implementation assumes so.
*
* @param metadata The metadata object to view as a map.
* @return A map view over the metadata object.
* @throws ClassCastException if the metadata object doesn't implement a metadata
* interface of the expected package.
*/
final Map<String,Object> asMap(final Object metadata) throws ClassCastException {
return standard.asValueMap(metadata, KeyNamePolicy.UML_IDENTIFIER, ValueExistencePolicy.ALL);
}
/**
* If the given metadata is a proxy generated by this {@code MetadataSource}, returns the
* identifier of that proxy. Such metadata don't need to be inserted again in the database.
*
* @param metadata The metadata to test.
* @return The identifier (primary key), or {@code null} if the given metadata is not a proxy.
*/
final String proxy(final Object metadata) {
return (metadata instanceof MetadataProxy) ? ((MetadataProxy) metadata).identifier(this) : null;
}
/**
* Searches for the given metadata in the database. If such metadata is found, then its
* identifier (primary key) is returned. Otherwise this method returns {@code null}.
*
* @param metadata The metadata to search for.
* @return The identifier of the given metadata, or {@code null} if none.
* @throws SQLException If an error occurred while searching in the database.
* @throws ClassCastException if the metadata object doesn't implement a metadata
* interface of the expected package.
*/
public String search(final Object metadata) throws ClassCastException, SQLException {
ensureNonNull("metadata", metadata);
String identifier = proxy(metadata);
if (identifier == null) {
/*
* Code lists don't need to be stored in the database. Some code list tables may
* be present in the database in order to ensure foreigner key constraints, but
* those tables are not used in anyway by the org.geotoolkit.metadata.sql package.
*/
if (metadata instanceof CodeList<?>) {
identifier = ((CodeList<?>) metadata).name();
} else {
final String table = getTableName(standard.getInterface(metadata.getClass()));
final Map<String,Object> asMap = asMap(metadata);
synchronized (statements) {
try (Statement stmt = statements.connection().createStatement()) {
identifier = search(table, null, asMap, stmt, buffer);
}
}
}
}
return identifier;
}
/**
* Searches for the given metadata in the database. If such metadata is found, then its
* identifier (primary key) is returned. Otherwise this method returns {@code null}.
*
* @param table The table where to search.
* @param columns The table columns as given by {@link #getExistingColumns}, or {@code null}.
* @param metadata A map view of the metadata to search for.
* @param stmt The statement to use for executing the query.
* @param buffer An initially buffer for creating the SQL query.
* @return The identifier of the given metadata, or {@code null} if none.
* @throws SQLException If an error occurred while searching in the database.
*/
final String search(final String table, Set<String> columns, final Map<String,Object> metadata,
final Statement stmt, final SQLBuilder buffer) throws SQLException
{
assert Thread.holdsLock(statements);
buffer.clear();
for (final Map.Entry<String,Object> entry : metadata.entrySet()) {
/*
* Gets the value and the column where this value is stored. If the value
* is non-null, then the column must exist otherwise the metadata will be
* considered as not found.
*/
Object value = extractFromCollection(entry.getValue());
final String column = entry.getKey();
if (columns == null) {
columns = getExistingColumns(table);
}
if (!columns.contains(column)) {
if (value != null) {
return null; // The column was mandatory for the searched metadata.
} else {
continue; // Do not include a non-existent column in the SQL query.
}
}
/*
* Tests if the value is an other metadata, in which case we will invoke this
* method recursively. Note that if a metadata dependency is not found, we can
* stop the whole process immediately.
*/
if (value instanceof CodeList<?>) {
value = ((CodeList<?>) value).name();
} else if (value != null) {
String dependency = proxy(value);
if (dependency != null) {
value = dependency;
} else {
final Class<?> type = value.getClass();
if (standard.isMetadata(type)) {
dependency = search(getTableName(standard.getInterface(type)),
null, asMap(value), stmt, new SQLBuilder(buffer));
if (dependency == null) {
return null; // Dependency not found.
}
value = dependency;
}
}
}
/*
* Builds the SQL statement with the resolved value.
*/
if (buffer.isEmpty()) {
buffer.append("SELECT ").append(ID_COLUMN).append(" FROM ")
.appendIdentifier(schema, table).append(" WHERE ");
} else {
buffer.append(" AND ");
}
buffer.appendIdentifier(column).appendCondition(value);
}
/*
* The SQL statement is ready, with metadata dependency (if any) resolved. We can now
* execute it. If more than one record is found, the identifier of the first one will
* be retained but a warning will be logged.
*/
String identifier = null;
try (ResultSet rs = stmt.executeQuery(buffer.toString())) {
while (rs.next()) {
final String candidate = rs.getString(1);
if (candidate != null) {
if (identifier == null) {
identifier = candidate;
} else if (!identifier.equals(candidate)) {
Logging.log(MetadataSource.class, "search", Errors.getResources(null).getLogRecord(
Level.WARNING, Errors.Keys.DuplicatedValuesForKey_1, candidate));
}
}
}
}
return identifier;
}
/**
* Returns the set of all columns in a table, or an empty set if none (never {@code null}).
* Because each table should have at least the {@value #ID_COLUMN} column, an empty set of
* columns will be understood as meaning that the table doesn't exist.
* <p>
* This method returns a direct reference to the cached set. The returned set shall be
* modified in-place if new columns are added in the database table.
*
* @param table The name of the table for which to get the columns.
* @return The set of columns, or an empty set if the table has not yet been created.
* @throws SQLException If an error occurred while querying the database.
*/
final Set<String> getExistingColumns(final String table) throws SQLException {
assert Thread.holdsLock(statements);
Set<String> columns = tables.get(table);
if (columns == null) {
columns = new HashSet<>();
/*
* Note: a null schema in the DatabaseMetadata. getExistingColumns(...) call means "do not
* take schema in account" - it does not mean "no schema" (the later is specified
* by an empty string). This match better what we want because if we do not specify
* a schema in a SELECT statement, then the actual schema used depends on the search
* path set in the database environment variables.
*/
try (ResultSet rs = statements.connection().getMetaData().getColumns(CATALOG, schema, table, null)) {
while (rs.next()) {
if (!columns.add(rs.getString("COLUMN_NAME"))) {
// Paranoiac check, but should never happen.
throw new SQLNonTransientException(table);
}
}
}
tables.put(table, columns);
}
return columns;
}
/**
* Returns an implementation of the specified metadata interface filled
* with the data referenced by the specified identifier. Alternatively,
* this method can also returns a {@link CodeList} element.
*
* @param <T> The parameterized type of the {@code type} argument.
* @param type The interface to implement (e.g. {@link org.opengis.metadata.citation.Citation}),
* or the {@link CodeList}.
* @param identifier The identifier used in order to locate the record for the metadata entity
* to be created. This is usually the primary key of the record to search for.
* @return An implementation of the required interface, or the code list element.
* @throws SQLException if a SQL query failed.
*/
public <T> T getEntry(final Class<T> type, final String identifier) throws SQLException {
ensureNonNull("type", type);
ensureNonNull("identifier", identifier);
/*
* IMPLEMENTATION NOTE: This method must not invoke any method which may access
* 'statements'. It is not allowed to acquire the lock on 'statements' neither.
*/
Object value;
if (CodeList.class.isAssignableFrom(type)) {
value = getCodeList(type, identifier);
} else {
final CacheKey key = new CacheKey(type, identifier);
synchronized (cache) {
value = cache.get(key);
if (value == null) {
value = Proxy.newProxyInstance(loader, new Class<?>[] {type, MetadataProxy.class},
new MetadataHandler(identifier, this));
cache.put(key, value);
}
}
}
return type.cast(value);
}
/**
* Returns the code of the given type and name. This method is defined for avoiding
* the warning message when the actual class is unknown (it must have been checked
* dynamically by the caller however).
*/
@SuppressWarnings({"unchecked","rawtypes"})
private static CodeList<?> getCodeList(final Class<?> type, final String name) {
return Types.forCodeName((Class) type, name, true);
}
/**
* Returns an attribute from a table.
*
* @param type The interface class. This is mapped to the table name in the database.
* @param method The method invoked. This is mapped to the column name in the database.
* @param identifier The primary key of the record to search for.
* @return The value of the requested attribute.
* @throws SQLException if the SQL query failed.
*/
final Object getValue(final Class<?> type, final Method method, final String identifier) throws SQLException {
final Class<?> valueType = method.getReturnType();
final boolean isCollection = Collection.class.isAssignableFrom(valueType);
final Class<?> elementType = isCollection ? Classes.boundOfParameterizedProperty(method) : valueType;
final boolean isMetadata = standard.isMetadata(elementType);
final String tableName = getTableName(type);
final String columnName = getColumnName(method);
final boolean isArray;
Object value;
synchronized (statements) {
if (getExistingColumns(tableName).contains(columnName)) {
/*
* Prepares the statement and executes the SQL query in this synchronized block.
* Note that the usage of 'result' must stay inside this synchronized block
* because we can not assume that JDBC connections are thread-safe.
*/
MetadataResult result = (MetadataResult) statements.remove(type);
if (result == null) {
final String query = buffer.clear().append("SELECT * FROM ")
.appendIdentifier(schema, tableName).append(" WHERE ")
.append(ID_COLUMN).append("=?").toString();
result = new MetadataResult(type, statements.connection().prepareStatement(query));
}
value = result.getObject(identifier, columnName);
isArray = (value instanceof java.sql.Array);
if (isArray) {
final java.sql.Array array = (java.sql.Array) value;
value = array.getArray();
array.free();
}
if (statements.put(type, result) != null) {
throw new AssertionError(type);
}
} else {
// Column does not exists.
value = null;
isArray = false;
}
}
/*
* If the value is an array and the return type is anything except an array of
* primitive type, ensure that the value is converted in an array of type Object[].
* In this process, resolve foreigner keys.
*/
if (isArray && (isCollection || !elementType.isPrimitive())) {
final Object[] values = new Object[Array.getLength(value)];
for (int i=0; i<values.length; i++) {
Object element = Array.get(value, i);
if (element != null) {
if (isMetadata) {
element = getEntry(elementType, element.toString());
} else try {
element = convert(elementType, element);
} catch (UnconvertibleObjectException e) {
throw new MetadataException(Errors.format(Errors.Keys.IllegalParameterValue_2,
columnName + '[' + i + ']', value), e);
}
}
values[i] = element;
}
value = values;
if (isCollection) {
Collection<Object> collection = Arrays.asList(values);
if (SortedSet.class.isAssignableFrom(valueType)) {
collection = new TreeSet<>(collection);
} else if (Set.class.isAssignableFrom(valueType)) {
collection = new LinkedHashSet<>(collection);
}
value = collection;
}
}
/*
* Now converts the value to its final type, including conversion of null
* value to empty collections if the return value should be a collection.
*/
if (value == null) {
if (isCollection) {
if (Set.class.isAssignableFrom(valueType)) {
return Collections.EMPTY_SET;
}
return Collections.EMPTY_LIST;
}
} else {
if (isMetadata) {
value = getEntry(elementType, value.toString());
} else try {
value = convert(elementType, value);
} catch (UnconvertibleObjectException e) {
throw new MetadataException(Errors.format(Errors.Keys.IllegalParameterValue_2, columnName, value), e);
}
if (isCollection) {
if (Set.class.isAssignableFrom(valueType)) {
return Collections.singleton(value);
}
return Collections.singletonList(value);
}
}
return value;
}
/**
* Converts the specified non-metadata value into an object of the expected type.
* The expected value is an instance of a class outside the metadata package, for
* example {@link String}, {@link InternationalString}, {@link URI}, <i>etc.</i>
*
* @throws UnconvertibleObjectException If the value can not be converter.
*/
@SuppressWarnings({"unchecked","rawtypes"})
private Object convert(final Class<?> targetType, Object value) throws UnconvertibleObjectException {
final Class<?> sourceType = value.getClass();
if (!targetType.isAssignableFrom(sourceType)) {
ObjectConverter converter = lastConverter;
if (converter == null || !converter.getSourceClass().equals(sourceType) ||
!targetType.equals(converter.getTargetClass()))
{
lastConverter = converter = ObjectConverters.find(sourceType, targetType);
}
value = converter.apply(value);
}
return value;
}
/**
* Closes the database connection used by this object.
*
* @throws SQLException If an error occurred while closing the connection.
*/
@Override
public void close() throws SQLException {
statements.close();
}
}