/*****************************************************************
* 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.cayenne.merge;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.sql.DataSource;
import org.apache.cayenne.CayenneException;
import org.apache.cayenne.CayenneRuntimeException;
import org.apache.cayenne.access.DataNode;
import org.apache.cayenne.access.DbLoader;
import org.apache.cayenne.access.DbLoaderDelegate;
import org.apache.cayenne.dba.DbAdapter;
import org.apache.cayenne.map.DataMap;
import org.apache.cayenne.map.DbAttribute;
import org.apache.cayenne.map.DbEntity;
import org.apache.cayenne.map.DbJoin;
import org.apache.cayenne.map.DbRelationship;
import org.apache.cayenne.map.ObjEntity;
/**
* Traverse a {@link DataNode} and a {@link DataMap} and create a group of
* {@link MergerToken}s to alter the {@link DataNode} datastore to match the
* {@link DataMap}.
*
*/
public class DbMerger {
private MergerFactory factory;
/**
* A method that return true if the given table name should be included. The default
* implementation include all tables.
*/
public boolean includeTableName(String tableName) {
return true;
}
/**
* Create and return a {@link List} of {@link MergerToken}s to alter the given
* {@link DataNode} to match the given {@link DataMap}
*/
public List<MergerToken> createMergeTokens(DataNode dataNode, DataMap dataMap) {
return createMergeTokens(dataNode.getAdapter(), dataNode.getDataSource(), dataMap);
}
/**
* Create and return a {@link List} of {@link MergerToken}s to alter the given
* {@link DataNode} to match the given {@link DataMap}
*/
public List<MergerToken> createMergeTokens(
DbAdapter adapter,
DataSource dataSource,
DataMap dataMap) {
factory = adapter.mergerFactory();
List<MergerToken> tokens = new ArrayList<MergerToken>();
Connection conn = null;
ResultSet rs = null;
try {
conn = dataSource.getConnection();
final DbMerger merger = this;
DbLoader dbLoader = new DbLoader(conn, adapter, new LoaderDelegate()) {
@Override
public boolean includeTableName(String tableName) {
return merger.includeTableName(tableName);
}
};
DataMap detectedDataMap = dbLoader.loadDataMapFromDB(
null,
null,
new DataMap());
Map<String, DbEntity> dbEntityToDropByName = new HashMap<String, DbEntity>(
detectedDataMap.getDbEntityMap());
for (DbEntity dbEntity : dataMap.getDbEntities()) {
String tableName = dbEntity.getName();
if (!includeTableName(tableName)) {
continue;
}
// look for table
DbEntity detectedEntity = findDbEntity(detectedDataMap, tableName);
if (detectedEntity == null) {
tokens.add(factory.createCreateTableToDb(dbEntity));
// TODO: does this work properly with createReverse?
for (DbRelationship rel : dbEntity.getRelationships()) {
tokens.add(factory.createAddRelationshipToDb(dbEntity, rel));
}
continue;
}
dbEntityToDropByName.remove(detectedEntity.getName());
checkRelationshipsToDrop(adapter, tokens, dbEntity, detectedEntity);
checkRows(tokens, dbEntity, detectedEntity);
checkRelationshipsToAdd(adapter, tokens, dbEntity, detectedEntity);
}
// drop table
// TODO: support drop table. currently, too many tables are marked for drop
for (DbEntity e : dbEntityToDropByName.values()) {
if (!includeTableName(e.getName())) {
continue;
}
tokens.add(factory.createDropTableToDb(e));
}
}
catch (SQLException e) {
throw new CayenneRuntimeException("", e);
}
finally {
if (rs != null) {
try {
rs.close();
}
catch (SQLException e) {
}
}
if (conn != null) {
try {
conn.close();
}
catch (SQLException e) {
}
}
}
return tokens;
}
private void checkRows(
List<MergerToken> tokens,
DbEntity dbEntity,
DbEntity detectedEntity) {
// columns to drop
for (DbAttribute detected : detectedEntity.getAttributes()) {
if (findDbAttribute(dbEntity, detected.getName()) == null) {
tokens.add(factory.createDropColumnToDb(dbEntity, detected));
}
}
// columns to add or modify
for (DbAttribute attr : dbEntity.getAttributes()) {
String columnName = attr.getName().toUpperCase();
DbAttribute detected = findDbAttribute(detectedEntity, columnName);
if (detected == null) {
tokens.add(factory.createAddColumnToDb(dbEntity, attr));
if (attr.isMandatory()) {
// TODO: default value
tokens.add(factory.createSetNotNullToDb(dbEntity, attr));
}
continue;
}
// check for not null
if (attr.isMandatory() != detected.isMandatory()) {
if (attr.isMandatory()) {
tokens.add(factory.createSetNotNullToDb(dbEntity, attr));
}
else {
tokens.add(factory.createSetAllowNullToDb(dbEntity, attr));
}
}
// TODO: check more types than char/varchar
// TODO: psql report VARCHAR for text column, not clob
switch (detected.getType()) {
case Types.VARCHAR:
case Types.CHAR:
if (attr.getMaxLength() != detected.getMaxLength()) {
tokens.add(factory.createSetColumnTypeToDb(
dbEntity,
detected,
attr));
}
break;
}
}
}
private void checkRelationshipsToDrop(
DbAdapter adapter,
List<MergerToken> tokens,
DbEntity dbEntity,
DbEntity detectedEntity) {
// relationships to drop
for (DbRelationship detected : detectedEntity.getRelationships()) {
if (findDbRelationship(dbEntity, detected) == null) {
// alter detected relationship to match entity and attribute names.
// (case sensitively)
DbEntity targetEntity = findDbEntity(dbEntity.getDataMap(), detected
.getTargetEntityName());
if (targetEntity == null) {
continue;
}
detected.setSourceEntity(dbEntity);
detected.setTargetEntity(targetEntity);
// manipulate the joins to match the DbAttributes in the model
for (DbJoin join : detected.getJoins()) {
DbAttribute sattr = findDbAttribute(dbEntity, join.getSourceName());
if (sattr != null) {
join.setSourceName(sattr.getName());
}
DbAttribute tattr = findDbAttribute(targetEntity, join
.getTargetName());
if (tattr != null) {
join.setTargetName(tattr.getName());
}
}
MergerToken token = factory
.createDropRelationshipToDb(dbEntity, detected);
if (detected.isToMany()) {
// default toModel as we can not do drop a toMany in the db. only
// toOne are represented using foreign key
token = token.createReverse(factory);
}
tokens.add(token);
}
}
}
private void checkRelationshipsToAdd(
DbAdapter adapter,
List<MergerToken> tokens,
DbEntity dbEntity,
DbEntity detectedEntity) {
// relationships to add
for (DbRelationship rel : dbEntity.getRelationships()) {
if (!includeTableName(rel.getTargetEntityName())) {
continue;
}
if (findDbRelationship(detectedEntity, rel) == null) {
// TODO: very ugly. perhaps MergerToken should have a .isNoOp()?
AbstractToDbToken t = (AbstractToDbToken) factory
.createAddRelationshipToDb(dbEntity, rel);
if (!t.createSql(adapter).isEmpty()) {
tokens.add(factory.createAddRelationshipToDb(dbEntity, rel));
}
}
}
}
/**
* case insensitive search for a {@link DbEntity} in a {@link DataMap} by name
*/
private DbEntity findDbEntity(DataMap map, String caseInsensitiveName) {
// TODO: create a Map with upper case keys?
for (DbEntity e : map.getDbEntities()) {
if (e.getName().equalsIgnoreCase(caseInsensitiveName)) {
return e;
}
}
return null;
}
/**
* case insensitive search for a {@link DbAttribute} in a {@link DbEntity} by name
*/
private DbAttribute findDbAttribute(DbEntity entity, String caseInsensitiveName) {
for (DbAttribute a : entity.getAttributes()) {
if (a.getName().equalsIgnoreCase(caseInsensitiveName)) {
return a;
}
}
return null;
}
/**
* search for a {@link DbRelationship} like rel in the given {@link DbEntity}
*/
private DbRelationship findDbRelationship(DbEntity entity, DbRelationship rel) {
for (DbRelationship candidate : entity.getRelationships()) {
if (equalDbJoinCollections(candidate.getJoins(), rel.getJoins())) {
return candidate;
}
}
return null;
}
/**
* Return true if the two unordered {@link Collection}s of {@link DbJoin}s are
* equal. Entity and Attribute names are compared case insensitively.
*/
private static boolean equalDbJoinCollections(
Collection<DbJoin> j1s,
Collection<DbJoin> j2s) {
if (j1s.size() != j2s.size()) {
return false;
}
for (DbJoin j1 : j1s) {
boolean foundPair = false;
for (DbJoin j2 : j2s) {
if ((j1.getSource() == null) || (j1.getSource().getEntity() == null)) {
continue;
}
if ((j1.getTarget() == null) || (j1.getTarget().getEntity() == null)) {
continue;
}
if ((j2.getSource() == null) || (j2.getSource().getEntity() == null)) {
continue;
}
if ((j2.getTarget() == null) || (j2.getTarget().getEntity() == null)) {
continue;
}
// check entity name
if (!j1.getSource().getEntity().getName().equalsIgnoreCase(
j2.getSource().getEntity().getName())) {
continue;
}
if (!j1.getTarget().getEntity().getName().equalsIgnoreCase(
j2.getTarget().getEntity().getName())) {
continue;
}
// check attribute name
if (!j1.getSourceName().equalsIgnoreCase(j2.getSourceName())) {
continue;
}
if (!j1.getTargetName().equalsIgnoreCase(j2.getTargetName())) {
continue;
}
foundPair = true;
break;
}
if (!foundPair) {
return false;
}
}
return true;
}
private static final class LoaderDelegate implements DbLoaderDelegate {
public void dbEntityAdded(DbEntity ent) {
}
public void dbEntityRemoved(DbEntity ent) {
}
public void objEntityAdded(ObjEntity ent) {
}
public void objEntityRemoved(ObjEntity ent) {
}
public boolean overwriteDbEntity(DbEntity ent) throws CayenneException {
return false;
}
}
}