/**
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at http://mozilla.org/MPL/2.0/. OpenMRS is also distributed under
* the terms of the Healthcare Disclaimer located at http://openmrs.org/license.
*
* Copyright (C) OpenMRS Inc. OpenMRS is a registered trademark and the OpenMRS
* graphic logo is a trademark of OpenMRS Inc.
*/
package org.openmrs.util;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import org.apache.commons.io.IOUtils;
import org.openmrs.annotation.Authorized;
import org.openmrs.api.context.Context;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import liquibase.Liquibase;
import liquibase.changelog.ChangeLogIterator;
import liquibase.changelog.ChangeLogParameters;
import liquibase.changelog.ChangeSet;
import liquibase.changelog.DatabaseChangeLog;
import liquibase.changelog.filter.ContextChangeSetFilter;
import liquibase.changelog.filter.DbmsChangeSetFilter;
import liquibase.changelog.filter.ShouldRunChangeSetFilter;
import liquibase.changelog.visitor.UpdateVisitor;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.exception.LiquibaseException;
import liquibase.exception.LockException;
import liquibase.lockservice.LockService;
import liquibase.parser.core.xml.XMLChangeLogSAXParser;
import liquibase.resource.CompositeResourceAccessor;
import liquibase.resource.FileSystemResourceAccessor;
import liquibase.resource.ResourceAccessor;
/**
* This class uses Liquibase to update the database. <br>
* <br>
* See src/main/resources/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 final Logger log = LoggerFactory.getLogger(DatabaseUpdater.class);
private static final String CHANGE_LOG_FILE = "liquibase-update-to-latest.xml";
private static final String CONTEXT = "core";
public static final String DATABASE_UPDATES_LOG_FILE = "liquibaseUpdateLogs.txt";
private static Integer authenticatedUserId;
/**
* Holds the update warnings generated by the custom liquibase changesets as they are executed
*/
private static volatile List<String> updateWarnings = null;
/**
* 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 requires some sort of user input. The
* error object will list of the user prompts and type of data for each prompt
* @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);
executeChangelog(changelog, userInput, null);
}
/**
* 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}
* @return A list of messages or warnings generated by the executed changesets
* @throws InputRequiredException if the changelog file requires some sort of user input. The
* error object will list of the user prompts and type of data for each prompt
*/
public static List<String> 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 {
return executeChangelog(changelog, CONTEXT, userInput, callback, null);
}
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
* @param cl {@link ClassLoader} to use to find the changeLogFile (or null to use
* {@link OpenmrsClassLoader})
* @return A list of messages or warnings generated by the executed changesets
* @throws Exception
*/
public static List<String> executeChangelog(String changeLogFile, String contexts, Map<String, Object> userInput,
ChangeSetExecutorCallback callback, ClassLoader cl) 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, DatabaseChangeLog databaseChangeLog, Database database)
throws LiquibaseException {
if (callback != null) {
callback.executing(changeSet, numChangeSetsToRun);
}
super.visit(changeSet, databaseChangeLog, database);
}
}
if (cl == null) {
cl = OpenmrsClassLoader.getInstance();
}
log.debug("Setting up liquibase object to run changelog: " + changeLogFile);
Liquibase liquibase = getLiquibase(changeLogFile, cl);
int numChangeSetsToRun = liquibase.listUnrunChangeSets(contexts).size();
Database database = null;
LockService lockHandler = null;
try {
database = liquibase.getDatabase();
lockHandler = LockService.getInstance(database);
lockHandler.waitForLock();
ResourceAccessor openmrsFO = new ClassLoaderFileOpener(cl);
ResourceAccessor fsFO = new FileSystemResourceAccessor();
DatabaseChangeLog changeLog = new XMLChangeLogSAXParser().parse(changeLogFile, new ChangeLogParameters(),
new CompositeResourceAccessor(openmrsFO, fsFO));
changeLog.setChangeLogParameters(liquibase.getChangeLogParameters());
changeLog.validate(database);
ChangeLogIterator logIterator = new ChangeLogIterator(changeLog, new ShouldRunChangeSetFilter(database),
new ContextChangeSetFilter(contexts), new DbmsChangeSetFilter(database));
database.checkDatabaseChangeLogTable(true, changeLog, new String[] { contexts });
logIterator.run(new OpenmrsUpdateVisitor(database, callback, numChangeSetsToRun), database);
}
catch (LiquibaseException e) {
throw e;
}
finally {
try {
lockHandler.releaseLock();
}
catch (Exception e) {
log.error("Could not release lock", e);
}
try {
database.getConnection().close();
}
catch (Exception e) {
//pass
}
}
return updateWarnings;
}
/**
* Ask Liquibase if it needs to do any updates. Only looks at the {@link #CHANGE_LOG_FILE}
*
* @return true/false whether database updates are required
* @throws LockException
* @should always have a valid update to latest file
*/
public static boolean updatesRequired() throws LockException {
log.debug("checking for updates");
List<OpenMRSChangeSet> changesets = getUnrunDatabaseChanges();
// if the db is locked, it means there was a crash
// or someone is executing db updates right now. either way
// returning true here stops the openmrs startup and shows
// the user the maintenance wizard for updates
if (isLocked() && changesets.isEmpty()) {
// if there is a db lock but there are no db changes we undo the
// lock
DatabaseUpdater.releaseDatabaseLock();
log.debug("db lock found and released automatically");
return false;
}
return !changesets.isEmpty();
}
/**
* Ask Liquibase if it needs to do any updates
*
* @param changeLogFilenames the filenames of all files to search for unrun changesets
* @return true/false whether database updates are required
* @should always have a valid update to latest file
*/
public static boolean updatesRequired(String... changeLogFilenames) throws Exception {
log.debug("checking for updates");
List<OpenMRSChangeSet> changesets = getUnrunDatabaseChanges(changeLogFilenames);
return !changesets.isEmpty();
}
/**
* 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<>();
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 (Exception e) {
// 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()
*
* @param changeLogFile the name of the file to look for the on classpath or filesystem
* @param cl the {@link ClassLoader} to use to find the file (or null to use
* {@link OpenmrsClassLoader})
* @return Liquibase object based on the current connection settings
* @throws Exception
*/
private static Liquibase getLiquibase(String changeLogFile, ClassLoader cl) throws Exception {
Connection connection = null;
try {
connection = getConnection();
}
catch (SQLException e) {
throw new Exception(
"Unable to get a connection to the database. Please check your openmrs runtime properties file and make sure you have the correct connection.username and connection.password set",
e);
}
if (cl == null) {
cl = OpenmrsClassLoader.getInstance();
}
try {
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(
new JdbcConnection(connection));
database.setDatabaseChangeLogTableName("liquibasechangelog");
database.setDatabaseChangeLogLockTableName("liquibasechangeloglock");
if (connection.getMetaData().getDatabaseProductName().contains("HSQL Database Engine")
|| connection.getMetaData().getDatabaseProductName().contains("H2")) {
// a hack because hsqldb and h2 seem to be checking table names in the metadata section case sensitively
database.setDatabaseChangeLogTableName(database.getDatabaseChangeLogTableName().toUpperCase());
database.setDatabaseChangeLogLockTableName(database.getDatabaseChangeLogLockTableName().toUpperCase());
}
ResourceAccessor openmrsFO = new ClassLoaderFileOpener(cl);
ResourceAccessor fsFO = new FileSystemResourceAccessor();
if (changeLogFile == null) {
changeLogFile = CHANGE_LOG_FILE;
}
database.checkDatabaseChangeLogTable(false, null, null);
return new Liquibase(changeLogFile, new CompositeResourceAccessor(openmrsFO, 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=default_storage_engine=InnoDB";
}
Class.forName(driver);
return DriverManager.getConnection(url, username, password);
}
/**
* Represents each change in the files referenced by 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(PrivilegeConstants.GET_DATABASE_CHANGES)
public static List<OpenMRSChangeSet> getDatabaseChanges() throws Exception {
Database database = null;
try {
Liquibase liquibase = getLiquibase(CHANGE_LOG_FILE, null);
database = liquibase.getDatabase();
DatabaseChangeLog changeLog = new XMLChangeLogSAXParser().parse(CHANGE_LOG_FILE, new ChangeLogParameters(),
liquibase.getFileOpener());
List<ChangeSet> changeSets = changeLog.getChangeSets();
List<OpenMRSChangeSet> results = new ArrayList<>();
for (ChangeSet changeSet : changeSets) {
OpenMRSChangeSet omrschangeset = new OpenMRSChangeSet(changeSet, database);
results.add(omrschangeset);
}
return results;
}
finally {
try {
if (database != null) {
database.getConnection().close();
}
}
catch (Exception e) {
//pass
}
}
}
/**
* @see DatabaseUpdater#getUnrunDatabaseChanges(String...)
*/
@Authorized(PrivilegeConstants.GET_DATABASE_CHANGES)
public static List<OpenMRSChangeSet> getUnrunDatabaseChanges() {
return getUnrunDatabaseChanges(CHANGE_LOG_FILE);
}
/**
* Looks at the specified liquibase change log files and returns all changesets in the files
* that have not been run on the database yet. If no argument is specified, then it looks at the
* current liquibase-update-to-latest.xml file
*
* @param changeLogFilenames the filenames of all files to search for unrun changesets
* @return list of change sets
*/
@Authorized(PrivilegeConstants.GET_DATABASE_CHANGES)
public static List<OpenMRSChangeSet> getUnrunDatabaseChanges(String... changeLogFilenames) {
log.debug("Getting unrun changesets");
Database database = null;
try {
if (changeLogFilenames == null) {
throw new IllegalArgumentException("changeLogFilenames cannot be null");
}
//if no argument, look ONLY in liquibase-update-to-latest.xml
if (changeLogFilenames.length == 0) {
changeLogFilenames = new String[] { CHANGE_LOG_FILE };
}
List<OpenMRSChangeSet> results = new ArrayList<>();
for (String changelogFile : changeLogFilenames) {
Liquibase liquibase = getLiquibase(changelogFile, null);
database = liquibase.getDatabase();
List<ChangeSet> changeSets = liquibase.listUnrunChangeSets(CONTEXT);
for (ChangeSet changeSet : changeSets) {
OpenMRSChangeSet omrschangeset = new OpenMRSChangeSet(changeSet, database);
results.add(omrschangeset);
}
}
return results;
}
catch (Exception e) {
throw new RuntimeException("Error occurred while trying to get the updates needed for the database. "
+ e.getMessage(), e);
}
finally {
try {
database.getConnection().close();
}
catch (Exception e) {
//pass
}
}
}
/**
* @return the authenticatedUserId
*/
public static Integer getAuthenticatedUserId() {
return authenticatedUserId;
}
/**
* @param userId the authenticatedUserId to set
*/
public static void setAuthenticatedUserId(Integer userId) {
authenticatedUserId = userId;
}
/**
* This method is called by an executing custom changeset to register warning messages.
*
* @param warnings list of warnings to append to the end of the current list
*/
public static void reportUpdateWarnings(List<String> warnings) {
if (updateWarnings == null) {
updateWarnings = new LinkedList<>();
}
updateWarnings.addAll(warnings);
}
/**
* This method writes the given text to the database updates log file located in the application
* data directory.
*
* @param text text to be written to the file
*/
public static void writeUpdateMessagesToFile(String text) {
PrintWriter writer = null;
File destFile = new File(OpenmrsUtil.getApplicationDataDirectory(), DatabaseUpdater.DATABASE_UPDATES_LOG_FILE);
try {
String lineSeparator = System.getProperty("line.separator");
Date date = Calendar.getInstance().getTime();
writer = new PrintWriter(new BufferedWriter(new FileWriter(destFile, true)));
writer.write("********** START OF DATABASE UPDATE LOGS AS AT " + date + " **********");
writer.write(lineSeparator);
writer.write(lineSeparator);
writer.write(text);
writer.write(lineSeparator);
writer.write(lineSeparator);
writer.write("*********** END OF DATABASE UPDATE LOGS AS AT " + date + " ***********");
writer.write(lineSeparator);
writer.write(lineSeparator);
//check if there was an error while writing to the file
if (writer.checkError()) {
log.warn("An Error occured while writing warnings to the database update log file'");
}
writer.close();
}
catch (FileNotFoundException e) {
log.warn("Failed to find the database update log file", e);
}
catch (IOException e) {
log.warn("Failed to write to the database update log file", e);
}
finally {
IOUtils.closeQuietly(writer);
}
}
/**
* This method releases the liquibase db lock after a crashed database update. First, it
* checks whether "liquibasechangeloglock" table exists in db. If so, it will check
* whether the database is locked. If that is also true, this means that last attempted db
* update crashed.<br>
* <br>
* This should only be called if the user is sure that no one else is currently running
* database updates. This method should be used if there was a db crash while updates
* were being written and the lock table was never cleaned up.
*
* @throws LockException
*/
public static synchronized void releaseDatabaseLock() throws LockException {
Database database = null;
try {
Liquibase liquibase = getLiquibase(null, null);
database = liquibase.getDatabase();
if (database.hasDatabaseChangeLogLockTable() && isLocked()) {
LockService.getInstance(database).forceReleaseLock();
}
}
catch (Exception e) {
throw new LockException(e);
}
finally {
try {
database.getConnection().close();
}
catch (Exception e) {
// pass
}
}
}
/**
* This method currently checks the liquibasechangeloglock table to see if there is a row
* with a lock in it. This uses the liquibase API to do this
*
* @return true if database is currently locked
*/
public static boolean isLocked() {
Database database = null;
try {
Liquibase liquibase = getLiquibase(null, null);
database = liquibase.getDatabase();
return LockService.getInstance(database).listLocks().length > 0;
}
catch (Exception e) {
return false;
}
finally {
try {
database.getConnection().close();
}
catch (Exception e) {
// pass
}
}
}
}