/** * The contents of this file are subject to the OpenMRS Public License * Version 1.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://license.openmrs.org * * Software distributed under the License is distributed on an "AS IS" * basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the * License for the specific language governing rights and limitations * under the License. * * Copyright (C) OpenMRS, LLC. All Rights Reserved. */ package org.openmrs.util; import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; import liquibase.ChangeSet; import liquibase.ClassLoaderFileOpener; import liquibase.CompositeFileOpener; import liquibase.DatabaseChangeLog; import liquibase.FileOpener; import liquibase.FileSystemFileOpener; import liquibase.Liquibase; import liquibase.database.Database; import liquibase.database.DatabaseFactory; import liquibase.exception.LiquibaseException; import liquibase.exception.LockException; import liquibase.lock.LockHandler; import liquibase.parser.ChangeLogIterator; import liquibase.parser.ChangeLogParser; import liquibase.parser.filter.ContextChangeSetFilter; import liquibase.parser.filter.DbmsChangeSetFilter; import liquibase.parser.filter.ShouldRunChangeSetFilter; import liquibase.parser.visitor.UpdateVisitor; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.openmrs.annotation.Authorized; import org.openmrs.api.context.Context; /** * This class uses Liquibase to update the database. <br/> * <br/> * See /metadata/model/liquibase-update-to-latest.xml for the changes. This class will also run * arbitrary liquibase xml files on the associated database as well. Details for the database are * taken from the openmrs runtime properties. * * @since 1.5 */ public class DatabaseUpdater { private static Log log = LogFactory.getLog(DatabaseUpdater.class); private final static String CHANGE_LOG_FILE = "liquibase-update-to-latest.xml"; private final static String CONTEXT = "core"; /** * Convenience method to run the changesets using Liquibase to bring the database up to a * version compatible with the code * * @throws InputRequiredException if the changelog file requirest some sort of user input. The * error object will list of the user prompts and type of data for each prompt * @see #update(Map) * @see #executeChangelog(String, Map) */ public static void executeChangelog() throws DatabaseUpdateException, InputRequiredException { executeChangelog(null, null); } /** * Run changesets on database using Liquibase to get the database up to the most recent version * * @param changelog the liquibase changelog file to use (or null to use the default file) * @param userInput nullable map from question to user answer. Used if a call to update(null) * threw an {@link InputRequiredException} * @throws DatabaseUpdateException * @throws InputRequiredException */ public static void executeChangelog(String changelog, Map<String, Object> userInput) throws DatabaseUpdateException, InputRequiredException { log.debug("Executing changelog: " + changelog); // a call back that, well, does nothing ChangeSetExecutorCallback doNothingCallback = new ChangeSetExecutorCallback() { public void executing(ChangeSet changeSet, int numChangeSetsToRun) { log.debug("Executing changeset: " + changeSet.getId() + " numChangeSetsToRun: " + numChangeSetsToRun); } }; executeChangelog(changelog, userInput, doNothingCallback); } /** * Interface used for callbacks when updating the database. Implement this interface and pass it * to {@link DatabaseUpdater#executeChangelog(String, Map, ChangeSetExecutorCallback)} */ public interface ChangeSetExecutorCallback { /** * This method is called after each changeset is executed. * * @param changeSet the liquibase changeset that was just run * @param numChangeSetsToRun the total number of changesets in the current file */ public void executing(ChangeSet changeSet, int numChangeSetsToRun); } /** * Executes the given changelog file. This file is assumed to be on the classpath. If no file is * given, the default {@link #CHANGE_LOG_FILE} is ran. * * @param changelog The string filename of a liquibase changelog xml file to run * @param userInput nullable map from question to user answer. Used if a call to * executeChangelog(<String>, null) threw an {@link InputRequiredException} * @throws InputRequiredException if the changelog file requirest some sort of user input. The * error object will list of the user prompts and type of data for each prompt */ public static void executeChangelog(String changelog, Map<String, Object> userInput, ChangeSetExecutorCallback callback) throws DatabaseUpdateException, InputRequiredException { log.debug("installing the tables into the database"); if (changelog == null) changelog = CHANGE_LOG_FILE; try { executeChangelog(changelog, CONTEXT, userInput, callback); } catch (Exception e) { throw new DatabaseUpdateException("There was an error while updating the database to the latest. file: " + changelog + ". Error: " + e.getMessage(), e); } } /** * This code was borrowed from the liquibase jar so that we can call the given callback * function. * * @param changeLogFile the file to execute * @param contexts the liquibase changeset context * @param userInput answers given by the user * @param callback the function to call after every changeset * @throws Exception */ public static void executeChangelog(String changeLogFile, String contexts, Map<String, Object> userInput, ChangeSetExecutorCallback callback) throws Exception { final class OpenmrsUpdateVisitor extends UpdateVisitor { private ChangeSetExecutorCallback callback; private int numChangeSetsToRun; public OpenmrsUpdateVisitor(Database database, ChangeSetExecutorCallback callback, int numChangeSetsToRun) { super(database); this.callback = callback; this.numChangeSetsToRun = numChangeSetsToRun; } @Override public void visit(ChangeSet changeSet, Database database) throws LiquibaseException { callback.executing(changeSet, numChangeSetsToRun); super.visit(changeSet, database); } } log.debug("Setting up liquibase object to run changelog: " + changeLogFile); Liquibase liquibase = getLiquibase(changeLogFile); int numChangeSetsToRun = liquibase.listUnrunChangeSets(contexts).size(); Database database = liquibase.getDatabase(); LockHandler lockHandler = LockHandler.getInstance(database); lockHandler.waitForLock(); try { database.checkDatabaseChangeLogTable(); FileOpener clFO = new ClassLoaderFileOpener(); FileOpener fsFO = new FileSystemFileOpener(); DatabaseChangeLog changeLog = new ChangeLogParser(new HashMap<String, Object>()).parse(changeLogFile, new CompositeFileOpener(clFO, fsFO)); changeLog.validate(database); ChangeLogIterator logIterator = new ChangeLogIterator(changeLog, new ShouldRunChangeSetFilter(database), new ContextChangeSetFilter(contexts), new DbmsChangeSetFilter(database)); logIterator.run(new OpenmrsUpdateVisitor(database, callback, numChangeSetsToRun), database); } catch (LiquibaseException e) { throw e; } finally { try { lockHandler.releaseLock(); } catch (LockException e) { log.error("Could not release lock", e); } try { database.getConnection().close(); } catch (Throwable t) { //pass } } } /** * Ask Liquibase if it needs to do any updates * * @return true/false whether database updates are required * @should always have a valid update to latest file */ public static boolean updatesRequired() throws Exception { log.debug("checking for updates"); List<OpenMRSChangeSet> changesets = getUnrunDatabaseChanges(); return changesets.size() > 0; } /** * Indicates whether automatic database updates are allowed by this server. Automatic updates * are disabled by default. In order to enable automatic updates, the admin needs to add * 'auto_update_database=true' to the runtime properties file. * * @return true/false whether the 'auto_update_database' has been enabled. */ public static Boolean allowAutoUpdate() { String allowAutoUpdate = Context.getRuntimeProperties().getProperty( OpenmrsConstants.AUTO_UPDATE_DATABASE_RUNTIME_PROPERTY, "false"); return "true".equals(allowAutoUpdate); } /** * Takes the default properties defined in /metadata/api/hibernate/hibernate.default.properties * and merges it into the user-defined runtime properties * * @see org.openmrs.api.db.ContextDAO#mergeDefaultRuntimeProperties(java.util.Properties) */ private static void mergeDefaultRuntimeProperties(Properties runtimeProperties) { // loop over runtime properties and precede each with "hibernate" if // it isn't already Set<Object> runtimePropertyKeys = new HashSet<Object>(); runtimePropertyKeys.addAll(runtimeProperties.keySet()); // must do it this way to prevent concurrent mod errors for (Object key : runtimePropertyKeys) { String prop = (String) key; String value = (String) runtimeProperties.get(key); log.trace("Setting property: " + prop + ":" + value); if (!prop.startsWith("hibernate") && !runtimeProperties.containsKey("hibernate." + prop)) runtimeProperties.setProperty("hibernate." + prop, value); } // load in the default hibernate properties from hibernate.default.properties InputStream propertyStream = null; try { Properties props = new Properties(); // TODO: This is a dumb requirement to have hibernate in here. Clean this up propertyStream = DatabaseUpdater.class.getClassLoader().getResourceAsStream("hibernate.default.properties"); OpenmrsUtil.loadProperties(props, propertyStream); // add in all default properties that don't exist in the runtime // properties yet for (Map.Entry<Object, Object> entry : props.entrySet()) { if (!runtimeProperties.containsKey(entry.getKey())) runtimeProperties.put(entry.getKey(), entry.getValue()); } } finally { try { propertyStream.close(); } catch (Throwable t) { // pass } } } /** * Get a connection to the database through Liquibase. The calling method /must/ close the * database connection when finished with this Liquibase object. * liquibase.getDatabase().getConnection().close() * * @return Liquibase object based on the current connection settings * @throws Exception */ private static Liquibase getLiquibase(String changeLogFile) throws Exception { Connection connection = null; try { connection = getConnection(); Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(connection); database.setDatabaseChangeLogTableName("liquibasechangelog"); database.setDatabaseChangeLogLockTableName("liquibasechangeloglock"); if (connection.getMetaData().getDatabaseProductName().contains("HSQL Database Engine")) { // a hack because hsqldb seems to be checking table names in the metadata section case sensitively database.setDatabaseChangeLogTableName(database.getDatabaseChangeLogTableName().toUpperCase()); database.setDatabaseChangeLogLockTableName(database.getDatabaseChangeLogLockTableName().toUpperCase()); } FileOpener clFO = new ClassLoaderFileOpener(); FileOpener fsFO = new FileSystemFileOpener(); if (changeLogFile == null) changeLogFile = CHANGE_LOG_FILE; return new Liquibase(changeLogFile, new CompositeFileOpener(clFO, fsFO), database); } catch (Exception e) { // if an error occurs, close the connection if (connection != null) connection.close(); throw e; } } /** * Gets a database connection for liquibase to do the updates * * @return a java.sql.connection based on the current runtime properties */ public static Connection getConnection() throws Exception { Properties props = Context.getRuntimeProperties(); mergeDefaultRuntimeProperties(props); String driver = props.getProperty("hibernate.connection.driver_class"); String username = props.getProperty("hibernate.connection.username"); String password = props.getProperty("hibernate.connection.password"); String url = props.getProperty("hibernate.connection.url"); // hack for mysql to make sure innodb tables are created if (url.contains("mysql") && !url.contains("InnoDB")) { url = url + "&sessionVariables=storage_engine=InnoDB"; } Class.forName(driver); return DriverManager.getConnection(url, username, password); } /** * Represents each change in the liquibase-update-to-latest */ public static class OpenMRSChangeSet { private String id; private String author; private String comments; private String description; private ChangeSet.RunStatus runStatus; private Date ranDate; /** * Create an OpenmrsChangeSet from the given changeset * * @param changeSet * @param database */ public OpenMRSChangeSet(ChangeSet changeSet, Database database) throws Exception { setId(changeSet.getId()); setAuthor(changeSet.getAuthor()); setComments(changeSet.getComments()); setDescription(changeSet.getDescription()); setRunStatus(database.getRunStatus(changeSet)); setRanDate(database.getRanDate(changeSet)); } /** * @return the author */ public String getAuthor() { return author; } /** * @param author the author to set */ public void setAuthor(String author) { this.author = author; } /** * @return the comments */ public String getComments() { return comments; } /** * @param comments the comments to set */ public void setComments(String comments) { this.comments = comments; } /** * @return the description */ public String getDescription() { return description; } /** * @param description the description to set */ public void setDescription(String description) { this.description = description; } /** * @return the runStatus */ public ChangeSet.RunStatus getRunStatus() { return runStatus; } /** * @param runStatus the runStatus to set */ public void setRunStatus(ChangeSet.RunStatus runStatus) { this.runStatus = runStatus; } /** * @return the ranDate */ public Date getRanDate() { return ranDate; } /** * @param ranDate the ranDate to set */ public void setRanDate(Date ranDate) { this.ranDate = ranDate; } /** * @return the id */ public String getId() { return id; } /** * @param id the id to set */ public void setId(String id) { this.id = id; } } /** * Looks at the current liquibase-update-to-latest.xml file and then checks the database to see * if they have been run. * * @return list of changesets that both have and haven't been run */ @Authorized(OpenmrsConstants.PRIV_VIEW_DATABASE_CHANGES) public static List<OpenMRSChangeSet> getDatabaseChanges() throws Exception { Database database = null; try { Liquibase liquibase = getLiquibase(CHANGE_LOG_FILE); database = liquibase.getDatabase(); DatabaseChangeLog changeLog = new ChangeLogParser(new HashMap<String, Object>()).parse(CHANGE_LOG_FILE, liquibase.getFileOpener()); List<ChangeSet> changeSets = changeLog.getChangeSets(); List<OpenMRSChangeSet> results = new ArrayList<OpenMRSChangeSet>(); for (ChangeSet changeSet : changeSets) { OpenMRSChangeSet omrschangeset = new OpenMRSChangeSet(changeSet, database); results.add(omrschangeset); } return results; } finally { try { if (database != null) { database.getConnection().close(); } } catch (Throwable t) { //pass } } } /** * Looks at the current liquibase-update-to-latest.xml file returns all changesets in that file * that have not been run on the database yet. * * @return list of changesets that haven't been run */ @Authorized(OpenmrsConstants.PRIV_VIEW_DATABASE_CHANGES) public static List<OpenMRSChangeSet> getUnrunDatabaseChanges() throws Exception { log.debug("Getting unrun changesets"); Database database = null; try { Liquibase liquibase = getLiquibase(null); database = liquibase.getDatabase(); List<ChangeSet> changeSets = liquibase.listUnrunChangeSets(CONTEXT); List<OpenMRSChangeSet> results = new ArrayList<OpenMRSChangeSet>(); for (ChangeSet changeSet : changeSets) { OpenMRSChangeSet omrschangeset = new OpenMRSChangeSet(changeSet, database); results.add(omrschangeset); } return results; } catch (Exception e) { throw new RuntimeException("error getting unrun updates on the database", e); } finally { try { database.getConnection().close(); } catch (Throwable t) { //pass } } } }