package er.extensions.migration; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import com.webobjects.eoaccess.EOAdaptor; import com.webobjects.eoaccess.EOAdaptorChannel; import com.webobjects.eoaccess.EOModel; import com.webobjects.eoaccess.EOSQLExpression; import com.webobjects.eoaccess.EOSynchronizationFactory; import com.webobjects.eocontrol.EOEditingContext; import com.webobjects.eocontrol.EOKeyValueQualifier; import com.webobjects.eocontrol.EOQualifier; import com.webobjects.foundation.NSArray; import com.webobjects.foundation.NSDictionary; import com.webobjects.foundation.NSMutableArray; import er.extensions.jdbc.ERXJDBCUtilities; /** * ERXMigrationDatabase, ERXMigrationTable, and ERXMigrationColumn exist to make * navigating the wonderous API of EOSynchronizationFactory not totally suck. * Additionally, these simple models provide a way to insulate yourself from a * dependency on EOModels during migrations while still taking advantage of the * database independence of the SQL generation that EOF provides. The concept is * inspired by the original migration by the Rails migrations API. Currently * this API is only suitable for SQL migrations, which is why the terminology is * based on the relational model vs EOF's more generic concepts like Models, * Entities, and Attributes. * <p> * Prior to this API, and still fully supported (and required for more * complicated operations), all migrations had to be written with SQL. The * downside of writing SQL is that you are writing database-specific operations, * which you must provide per-database implementations of. EOF already supports * an API for database-agnostic SQL generation that via the * EOSynchronizationFactory family of interfaces, but that API is overly * complicated. ERXMigrationDatabase aims to provide a much simpler API on top * of EOSynchronizationFactory that lets you perform common database-agnostic * operations like adding and deleting columns, creating and dropping tables, * adding primary keys, and adding foreign keys. * <p> * ERXMigrationDatabase is conceptually similar to an EOModel, ERXMigrationTable * to an EOEntity, and ERXMigrationColumn to an EOAttribute. The names were * specifically chosen to make the SQL-specific nature of the API clear * (currently most of the API does not expose SQL-ness, but I'm assuming that in * the future it may as the complexity of the operations provided increases). * All of the API allows you to build an in-memory model of your structural * changes along with some "perform now" method calls that actual execute SQL * commands against the provided adaptor channel. * <p> * Let's take a look at some examples. Take the very common case of a migration * that just adds a new column to a table: * <pre><code> * ERXMigrationDatabase.database(channel).existingTableNamed("Request").newStringColumn("requestedByEmailAddress", 255, true); * </code></pre> * Another more complex case is that you are introducing an entirely new table * that has a foreign key to some existing table: * <pre><code> * ERXMigrationDatabase database = ERXMigrationDatabase.database(channel); * ERXMigrationTable table = ERXMigrationDatabase.database(channel).newTableNamed("TestPerson"); * table.newStringColumn("FirstName", 100, false); * table.newStringColumn("LastName", 100, false); * table.newStringColumn("EmailAddress", 100, false); * table.newStringColumn("PhoneNumber", 10, true); * table.newIntegerColumn("PantSize", true); * table.newTimestampColumn("Birthdate", true); * table.newBigDecimalColumn("HourlyRate", 32, 4, true); * table.newFloatColumn("Rating", 10, 2, true); * table.newBooleanColumn("Married", false); * table.newIntBooleanColumn("Bald", false); * table.newIntegerColumn("CompanyID", false); * table.create(); * table.addForeignKey(table.existingColumnNamed("CompanyID"), database.existingTableNamed("Company").existingColumnNamed("companyID")); * </code></pre> * In the above examples, database.existingTableNamed and * table.existingColumnNamed are called. Calling table/database.existingXxx() * does not perform database reverse engineering. It only creates a stub entry * that is enough to perform operations like deleting, renaming, foreign keys, * etc. Calling table.newXxx does not create the element in the database if the * table is new, rather it returns a metadata wrapper (similar to EOAttribute, * etc, but with migration-specific API's). However, if the table already * exists, calling .newXxxColumn on the table will create the column * immediately. You should generally not call .create() on an object you * obtained from a call to .existingXxx, because it will only be a stub and * generally insufficient to actually create in the database. The call to * .existingXxx implies that the corresponding element already exists in the * database. If you are creating an entire table, you can use the batching API * like the second example where you can call database.newTableNamed(), then * .newColumn all the columns in it, followed by a table.create() to create the * entire block. For foreign keys, you must have .create()'d both tables (or use * existing tables) prior to calling the foreign key methods. * <p> * It's important to note that this API relies entirely on * EOSynchronizationFactory. If the sync factory for your plugin is wrong, the * SQL generation in the ERXMigrationDatabase API's will likewise be wrong. * * @author mschrag */ public class ERXMigrationDatabase { private static final Logger log = LoggerFactory.getLogger(ERXMigrationDatabase.class); private EOModel _model; private EOAdaptorChannel _adaptorChannel; private NSMutableArray<ERXMigrationTable> _tables; private NSArray<String> _languages; /** * Constructs an ERXMigrationDatabase * * @param adaptorChannel * the adaptor channel to connect to * @param model the model being migrated (necessary for more reliable sql generation) */ private ERXMigrationDatabase(EOAdaptorChannel adaptorChannel, EOModel model) { this(adaptorChannel, model, null); } /** * Constructs an ERXMigrationDatabase * * @param adaptorChannel * the adaptor channel to connect to * @param model the model being migrated (necessary for more reliable sql generation) * @param languages the languages to use for localization */ private ERXMigrationDatabase(EOAdaptorChannel adaptorChannel, EOModel model, NSArray<String> languages) { _adaptorChannel = adaptorChannel; _model = model; _tables = new NSMutableArray<>(); _languages = languages; if(_languages == null) { _languages = NSArray.EmptyArray; } } /** * Returns the synchronization factory for this adaptor. * * @return the synchronization factory for this adaptor */ public EOSynchronizationFactory synchronizationFactory() { return (EOSynchronizationFactory) adaptor().synchronizationFactory(); } /** * Returns the adaptor for the given channel. * * @return the adaptor for the given channel */ public EOAdaptor adaptor() { return _adaptorChannel.adaptorContext().adaptor(); } /** * Returns the model associated with this migration. * * @return the model associated with this migration */ public EOModel model() { return _model; } /** * Returns the configured default languages for this migration. * * @return the configured default languages for this migration. */ public NSArray<String> languages() { return _languages; } /** * Returns the adaptor channel. * * @return the adaptor channel */ public EOAdaptorChannel adaptorChannel() { return _adaptorChannel; } /** * Returns an ERXMigrationTable with the given table name. This method does * not perform any database reverse engineering. If you ask for an existing * table, it will only return a stub of the table that should be sufficient * for performing column operations and miscellaneous table operations like * dropping. If you call newTableNamed, existingTableNamed will return the * tables you create. * * @param name * the name of the table to lookup * * @return an ERXMigrationTable instance */ @SuppressWarnings("unchecked") public ERXMigrationTable existingTableNamed(String name) { NSArray<ERXMigrationTable> existingTables = EOQualifier.filteredArrayWithQualifier(_tables, new EOKeyValueQualifier("name", EOQualifier.QualifierOperatorCaseInsensitiveLike, name)); ERXMigrationTable table; if (existingTables.count() == 0) { table = new ERXMigrationTable(this, name); table._setNew(false); _tables.addObject(table); } else { table = existingTables.objectAtIndex(0); } return table; } /** * Shortcut to ERXMigrationTable.existingColumnNamed(String). * * @param tableName the name of the existing table * @param columnName the name of the existing column * @return the ERXMigrationColumn */ public ERXMigrationColumn existingColumnNamed(String tableName, String columnName) { ERXMigrationTable table = existingTableNamed(tableName); return table.existingColumnNamed(columnName); } /** * Creates a new blank ERXMigrationTable. This is essentially the same as * calling existingTableNamed except that it performs some simple validation * to make sure this table hasn't been created in this ERXMigrationDatabase * yet. Note that this check is not checking the actual database -- it is * only verifying that you have not called newTableNamed or * existingTableNamed on the name you provide. After calling newTableNamed, * the instance returned from this method will also be returned from calls * to existingTableNamed. The table will not be created from this call, only * an object model is built. * * @param name * the name of the table to create * @return a new ERXMigrationTable */ @SuppressWarnings("unchecked") public ERXMigrationTable newTableNamed(String name) { NSArray<ERXMigrationTable> existingTables = EOQualifier.filteredArrayWithQualifier(_tables, new EOKeyValueQualifier("name", EOQualifier.QualifierOperatorCaseInsensitiveLike, name)); if (existingTables.count() > 0) { throw new IllegalArgumentException("You've already referenced a table named '" + name + "'."); } ERXMigrationTable newTable = new ERXMigrationTable(this, name); _tables.addObject(newTable); return newTable; } /** * Returns a blank EOModel with the connection dictionary from the adaptor. * * @return a blank EOModel */ public EOModel _blankModel() { EOModel blankModel = new EOModel(); NSDictionary connectionDictionary = null; if (_model != null) { connectionDictionary = _model.connectionDictionary(); } if (connectionDictionary == null) { connectionDictionary = _adaptorChannel.adaptorContext().adaptor().connectionDictionary(); } blankModel.setConnectionDictionary(connectionDictionary); blankModel.setAdaptorName(_adaptorChannel.adaptorContext().adaptor().name()); return blankModel; } /** * Notification callback to tell the database that the user dropped the * given table. * * @param table * the table that was dropped */ public void _tableDropped(ERXMigrationTable table) { _tables.removeObject(table); } /** * @see #productName() * @param name name of database to match productName() * @return <code>true</code> if productName().equals(name) */ public boolean is(String name) { return productName().equals(name); } /** * @see er.extensions.jdbc.ERXJDBCUtilities#databaseProductName(EOAdaptorChannel) * @return database product name */ public String productName() { return ERXJDBCUtilities.databaseProductName(adaptorChannel()); } /** * Returns an ERXMigrationDatabase for the given EOAdaptorChannel. This will * return a new ERXMigrationDatabase for every call, so if you need to * perform multiple operations within a single database instance (for * instance, adding foreign keys that talk to two tables), you should * operate within a single ERXMigrationDatabase instance. If you have a * model, you should use database(adaptorChannel, model) instead of this * variant so that migrations can use the connection dictionary that is * closest to being correct. * * @param adaptorChannel * the adaptor channel to operate within * @return an ERXMigrationDatabase */ public static ERXMigrationDatabase database(EOAdaptorChannel adaptorChannel) { return new ERXMigrationDatabase(adaptorChannel, null); } /** * Returns an ERXMigrationDatabase for the given EOAdaptorChannel. This will * return a new ERXMigrationDatabase for every call, so if you need to * perform multiple operations within a single database instance (for * instance, adding foreign keys that talk to two tables), you should * operate within a single ERXMigrationDatabase instance. * * @param adaptorChannel * the adaptor channel to operate within * @param model * the model that corresponds to this table * @param languages the languages to use for localization * @return an ERXMigrationDatabase */ public static ERXMigrationDatabase database(EOAdaptorChannel adaptorChannel, EOModel model, NSArray<String> languages) { return new ERXMigrationDatabase(adaptorChannel, model, languages); } /** * Returns an ERXMigrationDatabase for the given EOAdaptorChannel. This will * return a new ERXMigrationDatabase for every call, so if you need to * perform multiple operations within a single database instance (for * instance, adding foreign keys that talk to two tables), you should * operate within a single ERXMigrationDatabase instance. * * @param adaptorChannel * the adaptor channel to operate within * @param model * the model that corresponds to this table * @return an ERXMigrationDatabase */ public static ERXMigrationDatabase database(EOAdaptorChannel adaptorChannel, EOModel model) { return new ERXMigrationDatabase(adaptorChannel, model); } /** * Throws an ERXMigrationFailedException if the array of expressions is * empty. Not all sync factories support all the listed operations, so this * makes sure that the requested operation doesn't silently fail. * * @param expressions * the expressions to check * @param operationName the name of the operation being performed (for better error messages) * @param required if true, an exception is thrown; if false, an error is logged */ public static void _ensureNotEmpty(NSArray<EOSQLExpression> expressions, String operationName, boolean required) { if (expressions == null || expressions.count() == 0) { if (required) { throw new ERXMigrationFailedException("Your EOSynchronizationFactory does not support the required '" + operationName + "' operation."); } log.error("Your EOSynchronizationFactory does not support the '{}' operation, so this migration will be skipped.", operationName); } } /** * Returns an NSArray of SQL strings that correspond to the NSArray of * EOSQLExpressions that were passed in. * * @param expressions * the expressions to retrieve SQL for * @return an NSArray of SQL strings */ @SuppressWarnings("unchecked") public static NSArray<String> _stringsForExpressions(NSArray<EOSQLExpression> expressions) { return (NSArray<String>) expressions.valueForKey("statement"); } /** * A convenience implementation of IERXMigration that passes in an * ERXMigrationDatabase instead of channel + model. * * @author mschrag */ public static abstract class Migration implements IERXMigration { public static final boolean ALLOWS_NULL = true; public static final boolean NOT_NULL = false; private NSArray<String> _languages; protected Migration() { this(null); } protected Migration(NSArray languages) { _languages = languages; } public NSArray<String> languages() { return _languages; } public void downgrade(EOEditingContext editingContext, EOAdaptorChannel channel, EOModel model) throws Throwable { downgrade(editingContext, ERXMigrationDatabase.database(channel, model, _languages)); } /** * @see IERXMigration#downgrade(EOEditingContext, EOAdaptorChannel, EOModel) * @param editingContext * the editing context * @param database * the migration database * @throws Throwable * if anything goes wrong */ public abstract void downgrade(EOEditingContext editingContext, ERXMigrationDatabase database) throws Throwable; /** * Overridden to return null by default */ public NSArray<ERXModelVersion> modelDependencies() { return null; } public void upgrade(EOEditingContext editingContext, EOAdaptorChannel channel, EOModel model) throws Throwable { upgrade(editingContext, ERXMigrationDatabase.database(channel, model, _languages)); } /** * @see IERXMigration#upgrade(EOEditingContext, EOAdaptorChannel, EOModel) * @param editingContext * the editing context * @param database * the migration database * @throws Throwable * if anything goes wrong */ public abstract void upgrade(EOEditingContext editingContext, ERXMigrationDatabase database) throws Throwable; } }