package er.extensions.migration;
import java.util.Enumeration;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.webobjects.eoaccess.EOAdaptorChannel;
import com.webobjects.eoaccess.EOEntity;
import com.webobjects.eoaccess.EOGeneralAdaptorException;
import com.webobjects.eoaccess.EOModel;
import com.webobjects.eoaccess.EOModelGroup;
import com.webobjects.eoaccess.EORelationship;
import com.webobjects.eocontrol.EOEditingContext;
import com.webobjects.foundation.NSArray;
import com.webobjects.foundation._NSUtilities;
import com.webobjects.jdbcadaptor.JDBCAdaptor;
import er.extensions.eof.ERXEC;
import er.extensions.eof.ERXEOAccessUtilities.ChannelAction;
import er.extensions.foundation.ERXProperties;
import er.extensions.jdbc.ERXJDBCConnectionAnalyzer;
import er.extensions.jdbc.ERXJDBCUtilities;
import er.extensions.jdbc.ERXSQLHelper;
/**
* ERXMigrator provides a simple mechanism for performing database migrations
* (both upgrading and downgrading).
* <p>
* To have ERXMigrator run at the start of your application, set
* er.migration.migrateAtStartup = true. This will perform a migration of your
* EOModels using a series of provided migration classes. By default, models are
* migrated in the order they appear in the EOModelGroup (that is, effectively
* random). If you want more control over the order at startup, you can set the
* er.migration.modelNames property to a comma-separate list of model names. The
* migrator will migrate them in this order.
* <p>
* For each EOModel, the migrator will lookup [modelName].MigrationClassPrefix
* and then append version numbers to that name. For instance, if your model
* named "AuthModel" is being migrated, and you set
* AuthModel.MigrationClassPrefix=com.mdimension.migration.AuthModel, it will
* then look for classes named like com.mdimension.migration.AuthModel0,
* com.mdimension.migration.AuthModel1, etc where the number corresponds to a
* zero-offset migration version. If MigrationClassPrefix is not set, the
* migrator will assume the prefix is simply the name of the model. Migration
* classes are resolved like WOComponents (i.e. packageless by name).
* <p>
* The com.mdimension.migration.AuthModelX classes should implement the
* IERXMigration interface, and should at least provide an implementation of the
* upgrade method. If you do not provide a downgrade method, you should throw
* ERXMigrationFailedException when that method is called to notify the system
* that the requested migration cannot be performed. As an example,
* AuthModel1.upgrade(..) will be called to move from version 1 to version 2,
* and AuthModel1.downgrade(..) will be called to move from version 2 back to
* version 1. Your lowest version number migration should throw an
* ERXMigrationFailedException when its downgrade method is called.
* <p>
* Because of complications with database locking, the system will not
* autocreate tables and per-model rows for you, so if you are using JDBC
* migration, you should create a table named _DBUpdater with the following
* (approximately) create statement:
*
* <pre>
* create table _dbupdater (
* modelname varchar(100) not null,
* version integer not null,
* updatelock integer not null,
* lockowner varchar(100)
* )
* </pre>
*
* and for each model you want to be able to migrate, you should:
*
* <pre>
* insert into _dbupdater(modelname, version, updatelock, lockowner) values ('YourModelName', -1, 0, NULL)
* </pre>
*
* Be aware that not all databases are able to perform DDL operations in a
* transaction. The result of this is that if a DDL operation fails, your
* database may be left in an unknown state because the subsequent rollback will
* fail. Version numbers only increase when each migration completes
* sucessfully, so in this case, your migration version would be left at the
* previous version number.
* <p>
* Startup migration runs in response to the
* ApplicationWillFinishLaunchingNotification, so you should not access your EO's
* until after that notification is complete. ApplicationWillFinishLaunchingNotification
* is used instead of ApplicationDidFinishLaunchingNotification so that the
* application will not start accepting requests before migration is complete.
* <p>
* If you are not extending ERXApplication, you can add Migrations into your own
* application by adding this to your Application constructor:
* <pre>
* NSNotificationCenter.defaultCenter().addObserver(this,
* new NSSelector("willFinishLaunching", ERXConstant.NotificationClassArray),
* WOApplication.ApplicationWillFinishLaunchingNotification, null);
* </pre>
*
* And implementing the willFinishLaunching method like this:
* <pre>
* public void willFinishLaunching(NSNotification n) {
* if (ERXMigrator.shouldMigrateAtStartup()) {
* new ERXMigrator(name() + "-" + number()).migrateToLatest();
* }
* }
* </pre>
*
* @property er.migration.migrateAtStartup if true, migrateToLatest is
* automatically called at startup
* @property er.migration.[adaptorName].lockClassName the name of the
* IERXMigrationLock class to use (defaults to
* er.extensions.migration.ERX[adaptorName]MigrationLock)
* @property er.migration.modelNames a comma-separated list of model names to be
* migrated in a particular order. If missing, it will default to
* modelgroup.models() order.
* @property er.migration.skipModelNames a comma-separated list of model names
* to NOT be migrated.
* @property [modelName].MigrationClassPrefix the prefix of the class name to
* use to upgrade the model named [modelName]. Defaults to
* [modelName].
*
* @author mschrag
*/
public class ERXMigrator {
private static final Logger log = LoggerFactory.getLogger(ERXMigrator.class);
/**
* Symbolic version number for migrating to "the latest" version.
*/
public static final int LATEST_VERSION = Integer.MAX_VALUE;
private String _lockOwnerName;
/**
* Constructs an ERXMigrator with the given lock owner. For an application,
* the lock owner name defaults to appname-instancenumber.
*
* @param lockOwnerName
* the name of the lock owner
*/
public ERXMigrator(String lockOwnerName) {
_lockOwnerName = lockOwnerName;
}
/**
* Returns whether or not migration should run at startup. Defaults to
* false.
*
* @return return true if migrations should run at startup
*/
public static boolean shouldMigrateAtStartup() {
return ERXProperties.booleanForKeyWithDefault("er.migration.migrateAtStartup", false);
}
/**
* Migrates all models specified in the default model group to the latest
* versions.
*
* @throws IllegalAccessException
* @throws InstantiationException
*/
@SuppressWarnings("unchecked")
public void migrateToLatest() {
EOModelGroup modelGroup = EOModelGroup.defaultGroup();
NSArray<String> modelNames = NSArray.EmptyArray;
String modelNamesStr = ERXProperties.stringForKey("er.migration.modelNames");
if (modelNamesStr == null) {
log.warn("er.migration.modelNames is not set, defaulting to modelGroup.models() order instead.");
modelNames = modelGroup.modelNames();
}
else {
modelNames = NSArray.componentsSeparatedByString(modelNamesStr, ",");
}
String skipModelNamesStr = ERXProperties.stringForKey("er.migration.skipModelNames");
NSArray<String> skipModelNames = NSArray.EmptyArray;
if (skipModelNamesStr != null) {
skipModelNames = NSArray.componentsSeparatedByString(skipModelNamesStr, ",");
}
Map<IERXMigration, ERXModelVersion> migrations = _buildDependenciesForModelsNamed(modelNames, skipModelNames);
Map<IERXPostMigration, ERXModelVersion> postMigrations = new LinkedHashMap<>();
Iterator<IERXMigration> migrationsIter = migrations.keySet().iterator();
while (migrationsIter.hasNext()) {
IERXMigration migration = migrationsIter.next();
ERXModelVersion modelVersion = migrations.get(migration);
EOModel model = modelVersion.model();
IERXMigrationLock migrationLock = databaseLockForModel(model);
EOEditingContext editingContext = newEditingContext();
editingContext.lock();
try {
ERXMigrationAction migrationAction = new ERXMigrationAction(editingContext, migration, modelVersion, migrationLock, _lockOwnerName, postMigrations);
try {
ERXSQLHelper helper = ERXSQLHelper.newSQLHelper(model);
try {
helper.prepareConnectionForSchemaChange(editingContext, model);
migrationAction.perform(editingContext, model.name());
}
finally {
helper.restoreConnectionSettingsAfterSchemaChange(editingContext, model);
}
}
catch (ERXMigrationFailedException e) {
throw e;
}
catch (EOGeneralAdaptorException t) {
new ERXJDBCConnectionAnalyzer(model.connectionDictionary());
throw new ERXMigrationFailedException("Failed to migrate model '" + model.name() + "'.", t);
}
catch (Throwable t) {
throw new ERXMigrationFailedException("Failed to migrate model '" + model.name() + "'.", t);
}
}
finally {
editingContext.unlock();
}
}
Iterator<IERXPostMigration> postMigrationsIter = postMigrations.keySet().iterator();
while (postMigrationsIter.hasNext()) {
IERXPostMigration postMigration = postMigrationsIter.next();
ERXModelVersion modelVersion = postMigrations.get(postMigration);
EOEditingContext editingContext = newEditingContext();
editingContext.lock();
try {
try {
if (log.isInfoEnabled()) {
log.info("Running post migration for {} version {} ...", modelVersion.model().name(), modelVersion.version());
}
postMigration.postUpgrade(editingContext, modelVersion.model());
editingContext.saveChanges();
}
catch (Throwable t) {
throw new ERXMigrationFailedException("Failed on post migrations for model '" + modelVersion.model().name() + "'.", t);
}
}
finally {
editingContext.unlock();
}
}
}
protected IERXMigrationLock databaseLockForModel(EOModel model) {
String adaptorName = model.adaptorName();
String migrationLockClassName = ERXProperties.stringForKeyWithDefault("er.migration." + adaptorName + ".lockClassName", "er.extensions.migration.ERX" + adaptorName + "MigrationLock");
IERXMigrationLock databaseLock;
try {
Class migrationLockClass = Class.forName(migrationLockClassName);
databaseLock = (IERXMigrationLock) migrationLockClass.newInstance();
}
catch (Throwable t) {
throw new ERXMigrationFailedException("Failed to create migration lock class '" + migrationLockClassName + "'.", t);
}
return databaseLock;
}
protected Map<IERXMigration, ERXModelVersion> _buildDependenciesForModelsNamed(NSArray<String> modelNames, NSArray<String> skipModelNames) {
Map<IERXMigration, ERXModelVersion> migrations = new LinkedHashMap<>();
try {
Map<String, Integer> versions = new HashMap<>();
EOModelGroup modelGroup = EOModelGroup.defaultGroup();
Enumeration modelNamesEnum = modelNames.objectEnumerator();
while (modelNamesEnum.hasMoreElements()) {
String modelName = (String) modelNamesEnum.nextElement();
if (!skipModelNames.containsObject(modelName)) {
EOModel model = modelGroup.modelNamed(modelName);
if (model == null) {
throw new IllegalArgumentException("There is no model named '" + modelName + "' in this model group.");
}
_buildDependenciesForModel(model, ERXMigrator.LATEST_VERSION, versions, migrations, skipModelNames);
}
}
Set<String> processedModelNames = new HashSet<>();
Set<String> pendingModelNames = new HashSet<>(versions.keySet());
while (!pendingModelNames.isEmpty()) {
Iterator<String> modelNamesIter = pendingModelNames.iterator();
while (modelNamesIter.hasNext()) {
String modelName = modelNamesIter.next();
EOModel model = modelGroup.modelNamed(modelName);
Enumeration entitiesEnum = model.entities().objectEnumerator();
while (entitiesEnum.hasMoreElements()) {
EOEntity entity = (EOEntity) entitiesEnum.nextElement();
EOEntity parentEntity = entity.parentEntity();
if (parentEntity != null && !parentEntity.model().equals(model)) {
EOModel parentModel = parentEntity.model();
_buildDependenciesForModel(parentModel, LATEST_VERSION, versions, migrations, skipModelNames);
}
Enumeration relationshipsEnum = entity.relationships().objectEnumerator();
while (relationshipsEnum.hasMoreElements()) {
EORelationship relationship = (EORelationship) relationshipsEnum.nextElement();
EOEntity destinationEntity = relationship.destinationEntity();
if (destinationEntity != null && !destinationEntity.model().equals(model)) {
EOModel destinationModel = destinationEntity.model();
_buildDependenciesForModel(destinationModel, LATEST_VERSION, versions, migrations, skipModelNames);
}
}
}
_buildDependenciesForModel(model, LATEST_VERSION, versions, migrations, skipModelNames);
processedModelNames.add(modelName);
}
pendingModelNames.addAll(versions.keySet());
pendingModelNames.removeAll(processedModelNames);
}
}
catch (InstantiationException e) {
throw new RuntimeException("Migration failed.", e);
}
catch (IllegalAccessException e) {
throw new RuntimeException("Migration failed.", e);
}
return migrations;
}
protected boolean canMigrateModel(EOModel model) {
String adaptorName = model.adaptorName();
if ("Memory".equals(adaptorName)) {
return true;
}
if ("JDBC".equals(adaptorName)) {
String url = (String)model.connectionDictionary().objectForKey(JDBCAdaptor.URLKey);
if ((url != null && url.toLowerCase().startsWith("jdbc:"))) {
return true;
}
String dataSourceJndiName = (String) model.connectionDictionary().objectForKey(JDBCAdaptor.DataSourceJndiNameKey);
if(dataSourceJndiName != null) {
return true;
}
}
return false;
}
protected void _buildDependenciesForModel(EOModel model, int migrateToVersion, Map<String, Integer> versions, Map<IERXMigration, ERXModelVersion> migrations, NSArray<String> skipModelNames) throws InstantiationException, IllegalAccessException {
if (!canMigrateModel(model)) {
return;
}
String modelName = model.name();
if (skipModelNames.containsObject(modelName)) {
log.error("'{}' was in skipModelNames, but got passed to the _buildDependenciesForModel anyway...", modelName);
return;
}
Integer migratorVersion = versions.get(modelName);
if (migratorVersion == null) {
migratorVersion = Integer.valueOf(ERXProperties.intForKeyWithDefault(modelName + ".InitialMigrationVersion", -1));
}
if (migratorVersion.intValue() != ERXMigrator.LATEST_VERSION) {
boolean done = false;
for (int versionNum = migratorVersion.intValue() + 1; !done && versionNum <= migrateToVersion; versionNum++) {
String migrationClassPrefix = ERXProperties.stringForKeyWithDefault(modelName + ".MigrationClassPrefix", modelName).trim();
String erMigrationClassName = migrationClassPrefix + versionNum;
String vendorMigrationClassName = migrationClassPrefix + ERXJDBCUtilities.databaseProductName(model) + versionNum;
Class erMigrationClass;
log.debug("Looking for migration '{}' ...", erMigrationClassName);
erMigrationClass = _NSUtilities.classWithName(erMigrationClassName);
if (erMigrationClass == null) {
log.debug("Looking for vendor-specific migration '{}' ...", vendorMigrationClassName);
erMigrationClass = _NSUtilities.classWithName(vendorMigrationClassName);
}
if (erMigrationClass != null) {
IERXMigration migration = (IERXMigration) erMigrationClass.newInstance();
versions.put(modelName, Integer.valueOf(versionNum));
NSArray<ERXModelVersion> migrationDependencies = migration.modelDependencies();
if (migrationDependencies != null) {
Enumeration<ERXModelVersion> migrationDependenciesEnum = migrationDependencies.objectEnumerator();
while (migrationDependenciesEnum.hasMoreElements()) {
ERXModelVersion modelVersion = migrationDependenciesEnum.nextElement();
EOModel dependsOnModel = modelVersion.model();
int dependsOnVersion = modelVersion.version();
_buildDependenciesForModel(dependsOnModel, dependsOnVersion, versions, migrations, skipModelNames);
}
}
migrations.put(migration, new ERXModelVersion(model, versionNum));
}
else {
done = true;
log.debug(" Migration {} and/or {} do not exist.", erMigrationClassName, vendorMigrationClassName);
versions.put(modelName, Integer.valueOf(ERXMigrator.LATEST_VERSION));
}
}
}
}
/**
* Subclasses can override this to return a different editing context.
*
* @return EOEditingContext to be used for migration
*/
protected EOEditingContext newEditingContext() {
return ERXEC.newEditingContext();
}
protected static class ERXMigrationAction extends ChannelAction {
private EOEditingContext _editingContext;
private IERXMigrationLock _migrationLock;
private IERXMigration _migration;
private ERXModelVersion _modelVersion;
private String _lockOwnerName;
private Map<IERXPostMigration, ERXModelVersion> _postMigrations;
public ERXMigrationAction(EOEditingContext editingContext, IERXMigration migration, ERXModelVersion modelVersion, IERXMigrationLock migrationLock, String lockOwnerName, Map<IERXPostMigration, ERXModelVersion> postMigrations) {
_editingContext = editingContext;
_modelVersion = modelVersion;
_migration = migration;
_migrationLock = migrationLock;
_lockOwnerName = lockOwnerName;
_postMigrations = postMigrations;
}
@Override
protected int doPerform(EOAdaptorChannel channel) {
EOModel model = _modelVersion.model();
boolean locked;
do {
locked = _migrationLock.tryLock(channel, model, _lockOwnerName);
if (!locked) {
try {
Thread.sleep(5 * 1000);
}
catch (InterruptedException e) {
// do nothing
}
}
// MS: Do we put a timeout here? It could take a very long time
// ...
}
while (!locked);
if (locked) {
try {
int currentVersion = _migrationLock.versionNumber(channel, model);
int nextVersion = _modelVersion.version();
if (currentVersion < nextVersion) {
log.info("Upgrading {} to version {} with migration '{}'.", model.name(), nextVersion, _migration);
_migration.upgrade(_editingContext, channel, model);
_migrationLock.setVersionNumber(channel, model, nextVersion);
_editingContext.saveChanges();
channel.adaptorContext().commitTransaction();
channel.adaptorContext().beginTransaction();
log.info("{} is now version {}", model.name(), nextVersion);
if (_migration instanceof IERXPostMigration) {
_postMigrations.put((IERXPostMigration) _migration, _modelVersion);
}
}
else {
log.debug("Already upgraded {} to {}, skipping", model.name(), nextVersion);
}
}
catch (Throwable t) {
throw new ERXMigrationFailedException("Migration failed.", t);
}
finally {
_migrationLock.unlock(channel, model);
}
}
return 0;
}
}
}