/*
* Copyright (c) 2012 Data Harmonisation Panel
*
* All rights reserved. This program and the accompanying materials are made
* available under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation, either version 3 of the License,
* or (at your option) any later version.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this distribution. If not, see <http://www.gnu.org/licenses/>.
*
* Contributors:
* HUMBOLDT EU Integrated Project #030962
* Data Harmonisation Panel <http://www.dhpanel.eu>
*/
package eu.esdihumboldt.hale.io.jdbc;
import java.io.IOException;
import java.sql.Array;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.xml.namespace.QName;
import de.fhg.igd.slf4jplus.ALogger;
import de.fhg.igd.slf4jplus.ALoggerFactory;
import eu.esdihumboldt.hale.common.core.io.IOProviderConfigurationException;
import eu.esdihumboldt.hale.common.core.io.ProgressIndicator;
import eu.esdihumboldt.hale.common.core.io.report.IOReport;
import eu.esdihumboldt.hale.common.core.io.report.IOReporter;
import eu.esdihumboldt.hale.common.core.io.report.impl.IOMessageImpl;
import eu.esdihumboldt.hale.common.instance.io.impl.AbstractInstanceWriter;
import eu.esdihumboldt.hale.common.instance.model.Instance;
import eu.esdihumboldt.hale.common.instance.model.InstanceCollection;
import eu.esdihumboldt.hale.common.instance.model.ResourceIterator;
import eu.esdihumboldt.hale.common.instance.model.TypeFilter;
import eu.esdihumboldt.hale.common.schema.geometry.GeometryProperty;
import eu.esdihumboldt.hale.common.schema.model.ChildDefinition;
import eu.esdihumboldt.hale.common.schema.model.PropertyDefinition;
import eu.esdihumboldt.hale.common.schema.model.TypeDefinition;
import eu.esdihumboldt.hale.common.schema.model.constraint.property.AutoGenerated;
import eu.esdihumboldt.hale.common.schema.model.constraint.property.NillableFlag;
import eu.esdihumboldt.hale.common.schema.model.constraint.property.Reference;
import eu.esdihumboldt.hale.common.schema.model.constraint.type.GeometryType;
import eu.esdihumboldt.hale.io.jdbc.constraints.DatabaseTable;
import eu.esdihumboldt.hale.io.jdbc.constraints.DefaultValue;
import eu.esdihumboldt.hale.io.jdbc.constraints.SQLArray;
import eu.esdihumboldt.hale.io.jdbc.constraints.SQLType;
import eu.esdihumboldt.hale.io.jdbc.constraints.internal.GeometryAdvisorConstraint;
/**
* Writes instance to a database through a JDBC connection.
*
* @author Simon Templer
*/
public class JDBCInstanceWriter extends AbstractInstanceWriter
implements JDBCConstants, JDBCProvider {
private static final ALogger log = ALoggerFactory.getLogger(JDBCInstanceWriter.class);
/**
* Name of the parameter specifying if the data may be written unordered.
*/
private static final String PARAM_UNORDERED = "unordered";
private final Map<TypeDefinition, Map<Object, Long>> typAuto = new HashMap<>();
private Map<TypeDefinition, Boolean> visitedType;
/**
* Default constructor.
*/
public JDBCInstanceWriter() {
super();
addSupportedParameter(PARAM_USER);
addSupportedParameter(PARAM_PASSWORD);
addSupportedParameter(PARAM_UNORDERED);
}
@Override
public boolean isCancelable() {
return true;
}
@Override
public void checkCompatibility() throws IOProviderConfigurationException {
super.checkCompatibility();
// XXX check if the target schema is a JDBC schema?
// this export needs the SQLTypes being set on the property types of
// instances to write
}
@Override
public Connection getConnection() throws SQLException {
return JDBCConnection.getConnection(this);
}
@Override
protected IOReport execute(ProgressIndicator progress, IOReporter reporter)
throws IOProviderConfigurationException, IOException {
InstanceCollection instances = getInstances();
Connection connection = null;
try {
// connect to the database
try {
connection = getConnection();
} catch (Exception e) {
reporter.error(new IOMessageImpl(e.getLocalizedMessage(), e));
reporter.setSuccess(false);
reporter.setSummary("Failed to connect to database.");
return reporter;
}
if (isWriteUnordered()) {
// write instances as they come in
writeInstances(connection, instances, progress, reporter);
}
else {
// write instances based on type order needed for insert
// (to avoid violating constraints)
Set<TypeDefinition> sortedSet = getSortedSchemas(
getTargetSchema().getMappingRelevantTypes());
for (TypeDefinition td : sortedSet) {
writeInstances(connection, instances.select(new TypeFilter(td)), progress,
reporter);
}
}
reporter.setSuccess(true);
} catch (Exception e) {
reporter.error(new IOMessageImpl(e.getLocalizedMessage(), e));
reporter.setSuccess(false);
reporter.setSummary("Saving instances to database failed.");
} finally {
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
// ignore
}
}
progress.end();
}
return reporter;
}
@Override
public boolean isPassthrough() {
return isWriteUnordered();
}
/**
* @return if instances are written unordered
*/
protected boolean isWriteUnordered() {
return getParameter(PARAM_UNORDERED).as(Boolean.class, false);
}
/**
* Write instances to a database connection Auto incremental keys are
* written to the database with the new generated values, and updated the
* foreign key values with this newly generated auto incremental values of
* the referenced column.
*
* <p>
* <b>Limitation:</b>
* </p>
* This implementation for auto generated key insert is supported only for
* those tables which has only single auto generated fields.
*
* @param connection the database connection
* @param instances the instances to write
* @param progress the progress indicator
* @param reporter the reporter
* @throws Exception if saving the instances fails
*/
private void writeInstances(Connection connection, InstanceCollection instances,
ProgressIndicator progress, IOReporter reporter) throws Exception {
connection.setAutoCommit(false);
boolean trackProgress = instances.hasSize();
progress.begin("Write instances to database",
(trackProgress) ? (instances.size()) : (ProgressIndicator.UNKNOWN));
// maps type definitions to prepared statements
Map<TypeDefinition, Map<Set<QName>, PreparedStatement>> typeStatements = new HashMap<TypeDefinition, Map<Set<QName>, PreparedStatement>>();
Map<TypeDefinition, Map<Set<QName>, Integer>> typeCount = new HashMap<TypeDefinition, Map<Set<QName>, Integer>>();
ResourceIterator<Instance> it = instances.iterator();
try {
while (it.hasNext() && !progress.isCanceled()) {
Instance instance = it.next();
TypeDefinition type = instance.getDefinition();
Set<QName> properties = new HashSet<QName>();
for (QName property : instance.getPropertyNames())
properties.add(property);
filterInsertProperties(type, properties);
// per type count
Map<Set<QName>, Integer> typeCountMap = typeCount.get(type);
if (typeCountMap == null) {
typeCountMap = new HashMap<Set<QName>, Integer>();
typeCount.put(type, typeCountMap);
}
Integer count = typeCountMap.get(properties);
if (count == null)
count = 0;
typeCountMap.put(properties, count + 1);
// get prepared statement for instance type
PreparedStatement statement = getInsertStatement(type, properties, typeStatements,
connection);
// populate insert statement with values
populateInsertStatementOrExecuteAutoIncStatement(statement, properties, instance,
reporter, connection);
if (count % 100 == 0) {
statement.executeBatch();
// TODO statement.getGeneratedKeys() / does not work with
// batches for PostgreSQL
}
if (trackProgress) {
progress.advance(1);
}
}
// execute remaining batches
for (Map<Set<QName>, PreparedStatement> typeSpecificMap : typeStatements.values()) {
if (progress.isCanceled())
break;
for (PreparedStatement statement : typeSpecificMap.values()) {
if (progress.isCanceled())
break;
statement.executeBatch();
}
}
if (!progress.isCanceled())
connection.commit();
else
connection.rollback();
} catch (Exception e) {
if (e instanceof SQLException) {
SQLException next = ((SQLException) e).getNextException();
while (next != null) {
log.error("SQL exception", next);
next = next.getNextException();
}
}
try {
connection.rollback();
} catch (Exception e1) {
// ignore
}
throw e;
} finally {
// close iterator
it.close();
// close statements
for (Map<Set<QName>, PreparedStatement> typeSpecificMap : typeStatements.values())
for (PreparedStatement statement : typeSpecificMap.values())
statement.close();
}
// right now cancel => rollback. Otherwise this would have to be in
// front of it.close()...
// if (progress.isCanceled() && it.hasNext()) {
// reporter.error(new IOMessageImpl("Saving to database was canceled, not all instances were saved.", null));
// }
}
/**
*
* This helps to store the mapping of the old value to the auto generated
* value for a particular type. If same type appears twice that means the
* sorting and filtering of the types has not been done properly.
*
* @param type a type definition whose child has the auto incremented
* constraint
* @param orgId original id as string
* @param genId auto incremented id
*/
private void addIDMapping(TypeDefinition type, Object orgId, Long genId) {
orgId = processLookupId(orgId);
Map<Object, Long> autoInc = typAuto.get(type);
if (autoInc == null) {
autoInc = new HashMap<>();
typAuto.put(type, autoInc);
}
autoInc.put(orgId, genId);
}
private Object processLookupId(Object orgId) {
if (orgId instanceof Number) {
return ((Number) orgId).longValue();
}
return orgId;
}
/**
*
* Retrieves the target schema and orders it based on the references, so
* that there should not be any integrity constrain violation exception.
* <p>
* <b> Algorithm for sorting </b>
* </p>
* The steps for sorting the types based on references are as follows:
* <ol>
* <li>Create a new <code>set</code> for the sorted types</li>
* <li>Create a visited type map to keep hold of the types whose references
* have already been checked.
*
* <li>Iterate over the types that needs to be sorted
* <ol>
* <li>If the type is already in the visited map means references for this
* type have already been checked. (Again checking could lead to cyclic
* referencing in the infinite loop)</li>
* <li>Exit the current recursion.</li>
* <li>Iterate over the child definition of the types and check for the
* referenced types</li>
* <li>If there are reference types then iterate over each referenced type
* and recurse to the step (3.1) with this iterated referenced type</li>
* <li>Traverse till the type whose childs do not have any reference, add
* this type to the sorted set first</li>
* <li>Add the iterated type</li>
*
* </ol>
* </li>
* <li>Add the sorted types in the sorted set</li>
* </ol>
*
*
* @param collection types to be sorted
*
* @return returns the sorted schema based on the references
*
*/
public Set<TypeDefinition> getSortedSchemas(Collection<? extends TypeDefinition> collection) {
Set<TypeDefinition> sortedSet = new LinkedHashSet<TypeDefinition>();
visitedType = new HashMap<TypeDefinition, Boolean>();
for (TypeDefinition td : collection) {
if (!sortedSet.contains(td)) {
List<TypeDefinition> references = getReferencesTd(td);
if (references != null)
sortedSet.addAll(references);
}
}
return sortedSet;
}
/**
* gets the list of the referenced type definitions. This is used for
* sorting the types based on the references. This method recursively calls
* itself till it finds a type which does not have any reference and that
* type to the sorted list as first element.
*
* @param td type definition of the reference
* @return list of type definitions of referenced
*/
private List<TypeDefinition> getReferencesTd(TypeDefinition td) {
if (visitedType.containsKey(td) && visitedType.get(td)) {
return null;
}
else {
visitedType.put(td, true);
}
List<TypeDefinition> referencedTD = new ArrayList<TypeDefinition>();
for (ChildDefinition<?> cd : td.getChildren()) {
PropertyDefinition pd = cd.asProperty();
if (pd != null) {
Reference ref = pd.getConstraint(Reference.class);
// check if it has the references
if (ref.getReferencedTypes() != null) {
for (TypeDefinition t : ref.getReferencedTypes()) {
List<TypeDefinition> references = getReferencesTd(t);
if (references != null)
referencedTD.addAll(references);
}
}
}
}
if (!referencedTD.contains(td))
referencedTD.add(td);
return referencedTD;
}
/**
* Filters the set of properties to only contain properties that can be used
* for inserting (e. g. no groups).
*
* @param type the type definition
* @param properties the available properties
*/
private void filterInsertProperties(TypeDefinition type, Set<QName> properties) {
for (ChildDefinition<?> child : type.getChildren()) {
if (properties.contains(child.getName())) {
if (child.asProperty() == null) {
// remove it since it is a group and no property
// XXX warn?
properties.remove(child.getName());
}
}
}
}
/**
* Create a prepared insert statement, based on the given type definition.
* Currently, auto incremented fields are not inserted into the statement.
*
* @param type the type definition
* @param properties the set properties of the instance for which this
* statement is
* @param typeStatements the already created statements
* @param connection the database connection
* @return the insert statement
* @throws SQLException if creating the prepared statement fails
*/
private PreparedStatement getInsertStatement(TypeDefinition type, Set<QName> properties,
Map<TypeDefinition, Map<Set<QName>, PreparedStatement>> typeStatements,
Connection connection) throws SQLException {
Map<Set<QName>, PreparedStatement> typeSpecificMap = typeStatements.get(type);
if (typeSpecificMap == null) {
typeSpecificMap = new HashMap<Set<QName>, PreparedStatement>();
typeStatements.put(type, typeSpecificMap);
}
PreparedStatement result = typeSpecificMap.get(properties);
if (result == null) {
String tableName = type.getConstraint(DatabaseTable.class).getFullTableName();
// create prepared statement SQL
StringBuffer pSql = new StringBuffer();
pSql.append("INSERT INTO ");
pSql.append(tableName);
pSql.append(" (");
StringBuffer valuesSql = new StringBuffer();
boolean first = true;
for (QName property : properties) {
AutoGenerated auto = ((PropertyDefinition) type.getChild(property))
.getConstraint(AutoGenerated.class);
// Auto incremented fields are not included in the insert
// statement (if writing is ordered)
if (!auto.isEnabled() || isWriteUnordered()) {
if (first)
first = false;
else {
pSql.append(", ");
valuesSql.append(",");
}
pSql.append('"').append(property.getLocalPart()).append('"');
valuesSql.append('?');
}
}
pSql.append(") VALUES (");
pSql.append(valuesSql);
pSql.append(")");
// XXX Actually we don't necessarily need the auto generated keys,
// we need the primary key!
// XXX , Statement.RETURN_GENERATED_KEYS does not work with batches
// in PostgreSQL
// Auto generated keys are returned, for creating a map with old
// value and auto generated.
// As it does not work with batches in PostGreSQL, each instance
// which has auto incremented ids are written one at a time and
// returned the auto generated key.
result = connection.prepareStatement(pSql.toString(), Statement.RETURN_GENERATED_KEYS);
typeSpecificMap.put(properties, result);
}
return result;
}
/**
* Populate a prepared insert statement with values from the given instance.
* Checks if the property has auto incremental constraint, if it has then it
* maps the original old id to the id that is auto incremented while
* inserting the value. This mapping is used when inserting the foreign key
* values associated with those auto incremented column ids whose value has
* been changed. Thus, insertion of the foreign key wont fail. It will
* either execute the statement directly or add it into the batches
* depending upon the auto incremented flag.
*
*
* @param statement the insert statement
* @param properties the properties to fill the statement with
* @param instance the instance
* @param reporter the reporter
* @param conn Connection (used for Geometry conversion for oracle)
* @throws SQLException if configuring the statement fails
*/
private void populateInsertStatementOrExecuteAutoIncStatement(PreparedStatement statement,
Set<QName> properties, Instance instance, IOReporter reporter, Connection conn)
throws SQLException {
TypeDefinition type = instance.getDefinition();
int index = 1;
Object oldValue = null;
boolean isAutoIncremented = false;
for (QName propertyName : properties) {
PropertyDefinition property = (PropertyDefinition) type.getChild(propertyName);
Object[] values = instance.getProperty(propertyName);
SQLType sqlType = property.getPropertyType().getConstraint(SQLType.class);
if (!sqlType.isSet()) {
reporter.error(new IOMessageImpl(
"SQL type not set. Please only export to schemas read from a database.",
null));
statement.setObject(index, null);
continue;
}
SQLArray arrayInfo = property.getPropertyType().getConstraint(SQLArray.class);
Object value;
if (arrayInfo.isArray() && arrayInfo.getDimension() <= 1) {
// array as multiple occurrence property
value = (values == null) ? (new Object[0]) : (values);
}
else {
// single value
if (values != null && values.length > 1)
reporter.warn(new IOMessageImpl(
"Multiple values for a property. Only exporting first.", null));
value = (values == null || values.length == 0) ? null : values[0];
}
AutoGenerated auto = property.getConstraint(AutoGenerated.class);
if (!isWriteUnordered()) {
// only able to handle auto-incremented IDs if instances are
// written ordered
if (auto.isEnabled()) {
isAutoIncremented = true;
if (value != null) {
oldValue = value;
}
continue;
}
}
Reference ref = property.getConstraint(Reference.class);
if (ref.getReferencedTypes() != null) {
TypeDefinition td = (TypeDefinition) ref.getReferencedTypes().toArray()[0];
Map<Object, Long> marshMallow = typAuto.get(td);
if (marshMallow != null && value != null) {
// lookup identifier for reference
value = marshMallow.get(processLookupId(value));
}
}
if (values == null || values.length == 0) {
// XXX The default value could be a function call.
// Better would be to leave the column out of the insert
// statement, or set it to the SQL keyword "DEFAULT".
DefaultValue defaultValue = property.getConstraint(DefaultValue.class);
if (defaultValue.isSet())
statement.setObject(index, defaultValue.getValue(), sqlType.getType());
else if (property.getConstraint(NillableFlag.class).isEnabled())
statement.setNull(index, sqlType.getType());
else {
// no default, not nillable, will not work...
// set it to null here and let query fail (probably)
// XXX maybe skip this insert?
statement.setNull(index, sqlType.getType());
reporter.warn(new IOMessageImpl(
"Property no value, not nillable, no default value, insert will probably fail.",
null));
}
}
else if (value == null)
statement.setNull(index, sqlType.getType());
else
setStatementParameter(statement, index, value, property, sqlType.getType(),
reporter, conn);
index++;
}
if (isAutoIncremented) {
statement.execute();
ResultSet rs = statement.getGeneratedKeys();
Long newValue = null;
while (rs.next()) {
newValue = rs.getLong(1);
}
addIDMapping(type, oldValue, newValue);
}
else {
statement.addBatch();
}
}
/**
* Set a prepared statement parameter value.
*
* @param statement the prepared statement
* @param index the parameter index
* @param value the value, not <code>null</code>
* @param propertyDef the associated property definition
* @param sqlType the SQL type
* @param reporter the reporter
* @param conn Connection (currently used for geometry conversion for oracle
* database
* @throws SQLException if setting the parameter fails
*/
@SuppressWarnings("unchecked")
private void setStatementParameter(PreparedStatement statement, int index, Object value,
PropertyDefinition propertyDef, int sqlType, IOReporter reporter, Connection conn)
throws SQLException {
if (propertyDef.getPropertyType().getConstraint(GeometryType.class).isGeometry()) {
// is a geometry column
// get the geometry advisor
@SuppressWarnings("rawtypes")
GeometryAdvisor advisor = propertyDef.getPropertyType()
.getConstraint(GeometryAdvisorConstraint.class).getAdvisor();
if (advisor != null) {
// use the advisor to convert the geometry
if (value instanceof GeometryProperty<?>) {
// XXX JTS geometry conversion needed beforehand?
try {
value = advisor.convertGeometry((GeometryProperty<?>) value,
propertyDef.getPropertyType(), conn);
} catch (Exception e) {
reporter.error(
new IOMessageImpl("Something went wrong during conversion", e));
}
}
else {
reporter.error(new IOMessageImpl(
"Geometry value is not of type GeometryProperty and could thus not be converted for the database",
null));
}
}
}
SQLArray arrayInfo = propertyDef.getPropertyType().getConstraint(SQLArray.class);
if (arrayInfo.isArray()) {
// is an array column
Object[] values;
if (value.getClass().isArray()) {
values = (Object[]) value;
}
else {
values = new Object[] { value };
}
// FIXME for multi-dimensional arrays, make sure internal structures
// are arrays?
// use SQL array as value
Array array = conn.createArrayOf(arrayInfo.getElementTypeName(), values);
value = array;
// FIXME collect arrays to allow them to be freed after the
// statement is executed?
}
// TODO handling of other types?
// set the value
statement.setObject(index, value, sqlType);
}
@Override
protected String getDefaultTypeName() {
return "Database";
}
}