/* This file is part of Cyclos (www.cyclos.org). A project of the Social Trade Organisation (www.socialtrade.org). Cyclos is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. Cyclos is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Cyclos; if not, write to the Free Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA */ package nl.strohalm.cyclos.setup; import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.Driver; import java.sql.DriverManager; import java.sql.SQLException; import java.util.Enumeration; import java.util.List; import java.util.Locale; import java.util.Properties; import nl.strohalm.cyclos.utils.JDBCWrapper; import nl.strohalm.cyclos.utils.conversion.LocaleConverter; import nl.strohalm.cyclos.utils.tasks.TaskRunner; import org.apache.commons.collections.CollectionUtils; import org.apache.commons.lang.StringUtils; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.hibernate.SessionFactory; import org.hibernate.cfg.Configuration; import org.hibernate.cfg.Environment; import org.hibernate.connection.ConnectionProvider; import org.hibernate.connection.DatasourceConnectionProvider; /** * Class used to manage database configuration, validate the connection, generate the database when in embedded mode and apply automatic schema * updates. * @author luis */ public class DataBaseConfiguration { public static boolean SKIP = false; private static final Log LOG = LogFactory.getLog(DataBaseConfiguration.class); private final Configuration configuration; private SessionFactory sessionFactory; private final TaskRunner taskRunner; private Class<?> driverToUnregister; public DataBaseConfiguration(final Configuration configuration, final TaskRunner taskRunner) { this.configuration = configuration; this.taskRunner = taskRunner; } public Configuration getConfiguration() { return configuration; } public SessionFactory getSessionFactory() { return sessionFactory; } public void release() { if (driverToUnregister != null) { Enumeration<Driver> drivers = DriverManager.getDrivers(); while (drivers.hasMoreElements()) { Driver driver = drivers.nextElement(); if (driverToUnregister.isInstance(driver)) { try { DriverManager.deregisterDriver(driver); } catch (Exception e) { // Ignore } } } } } public void run() { final Properties properties = configuration.getProperties(); warnTrailingSpaces(properties); // Define some fixed Cyclos properties for Hibernate initCommonProperties(properties); // Check if all tests must be skipped final String skipTestsProperty = StringUtils.trimToNull(properties.getProperty("cyclos.database.skipTests")); final boolean skipTests = SKIP || skipTestsProperty == null || Boolean.valueOf(skipTestsProperty); if (skipTests) { if (!SKIP && skipTestsProperty == null) { LOG.info("Skipping Cyclos database check, as cyclos.properties doesn't set cyclos.database.skipTests=false"); } return; } // Delegate the database initialization to the task runner taskRunner.handleDatabaseInitialization(new Runnable() { @Override public void run() { handleDatabase(properties); } }); } private void handleDatabase(final Properties properties) { // Retrieve the connection final String dataSource = StringUtils.trimToNull(properties.getProperty(Environment.DATASOURCE)); Connection connection; String connectionLocation; if (dataSource != null) { // Use Hibernate's own DatasourceConnectionProvider when using a JNDI datasource final ConnectionProvider provider = new DatasourceConnectionProvider(); provider.configure(properties); try { connection = provider.getConnection(); } catch (final SQLException e) { final String msg = "Error connecting to datasource at " + dataSource; LOG.error(msg); throw new RuntimeException(msg, e); } connectionLocation = dataSource; } else { // Manually verify the connection final String driverClass = StringUtils.trimToNull(properties.getProperty(Environment.DRIVER)); validateDriver(driverClass); final String url = properties.getProperty(Environment.URL); final String username = properties.getProperty(Environment.USER); final String password = properties.getProperty(Environment.PASS); connection = validateConnection(url, username, password); connectionLocation = url; } final JDBCWrapper jdbc = new JDBCWrapper(connection); // Check whether we will create the database if necessary final boolean embedded = Boolean.valueOf(properties.getProperty("cyclos.embedded.enable", "false")); // Get the connection meta data boolean dataBaseExists = true; String dataBaseName; String dataBaseVersion; try { final DatabaseMetaData metaData = connection.getMetaData(); dataBaseName = metaData.getDatabaseProductName(); dataBaseVersion = metaData.getDatabaseProductVersion(); } catch (final SQLException e) { throw new RuntimeException("Error reading database metadata", e); } // Check if the database exists, by reading the current version final String currentVersion; try { currentVersion = readCurrentVersion(jdbc); if (currentVersion != null) { LOG.info(String.format("Cyclos database version %s found on %s version %s", currentVersion, dataBaseName, dataBaseVersion)); } dataBaseExists = currentVersion != null; // Check for new versions schema upgrade if (dataBaseExists) { final boolean autoUpgrade = Boolean.valueOf(properties.getProperty("cyclos.autoSchemaUpgrade.enable", "false")); if (autoUpgrade) { // Run the schema upgrade final String newVersion = upgradeSchema(currentVersion, jdbc); if (!currentVersion.equals(newVersion)) { // The version has changed. Add a custom property on the configuration properties for the cache // manager to clear the current cache, because new entities may be incompatible with cached versions properties.setProperty("cyclos.versionHasChanged", "true"); } } } else { // For MySQL connections, we should ensure that the database is set to utf8 if (embedded && dataBaseName.toLowerCase().equals("mysql")) { try { jdbc.commit(); jdbc.execute("alter database character set utf8"); } catch (final SQLException e) { e.printStackTrace(); // Ignore } } } } finally { // Close the connection try { connection.close(); } catch (final SQLException e) { } } // Run the setup if needed if (!dataBaseExists) { if (embedded) { final boolean smsEmbedded = Boolean.valueOf(properties.getProperty("cyclos.embedded.sms.enable", "false")); LOG.info("Database is empty. Running setup to populate it"); sessionFactory = configuration.buildSessionFactory(); final Locale locale = LocaleConverter.instance().valueOf(properties.getProperty("cyclos.embedded.locale", "en_US")); final Setup setup = new Setup(configuration, sessionFactory); setup.setLocale(locale); setup.setCreateDataBase(true); setup.setCreateBasicData(true); setup.setCreateInitialData(true); setup.setCreateSmsData(smsEmbedded); setup.setForce(true); setup.execute(); } else { throw new RuntimeException("Cyclos database not found at " + connectionLocation); } } } /** * Initialize some common properties for Hibernate */ private void initCommonProperties(final Properties properties) { // The transaction isolation level if (StringUtils.isEmpty(properties.getProperty("hibernate.connection.isolation"))) { properties.setProperty("hibernate.connection.isolation", "" + Connection.TRANSACTION_READ_COMMITTED); } final boolean secondLevelCacheEnabled = Boolean.parseBoolean(properties.getProperty("hibernate.cache.use_second_level_cache", "false")); if (secondLevelCacheEnabled) { // The second level cache provider if (StringUtils.isEmpty(properties.getProperty("hibernate.cache.region.factory_class"))) { properties.setProperty("hibernate.cache.region.factory_class", "net.sf.ehcache.hibernate.EhCacheRegionFactory"); } } // For MySQL, set the character encoding to utf8 String url = StringUtils.trimToNull(properties.getProperty("hibernate.connection.url")); if (url != null && url.toLowerCase().startsWith("jdbc:mysql:")) { if (!url.contains("useUnicode")) { url += (url.contains("?") ? "&" : "?") + "useUnicode=true"; } if (!url.contains("characterEncoding")) { url += (url.contains("?") ? "&" : "?") + "characterEncoding=utf8"; } properties.setProperty("hibernate.connection.url", url); } } /** * Read the current version from the database */ private String readCurrentVersion(final JDBCWrapper jdbc) { try { return jdbc.readScalarAsString("select version from application"); } catch (final SQLException e) { return null; } } /** * Apply automatic upgrades on the schema */ @SuppressWarnings("deprecation") private String upgradeSchema(final String originalVersion, final JDBCWrapper jdbc) { String currentVersion = originalVersion; final VersionHistory history = new VersionHistoryReader().read(); final List<Version> intermediateVersions = history.upgrade(originalVersion); if (intermediateVersions == null) { LOG.warn("Unknown version on database: " + originalVersion); return originalVersion; } String databaseName; try { databaseName = jdbc.getConnection().getMetaData().getDatabaseProductName(); } catch (final SQLException e) { throw new RuntimeException("Error reading database name", e); } for (final Version version : intermediateVersions) { final String newVersion = version.getLabel(); // Apply the statements final List<String> statements = version.getStatements(databaseName); LOG.info(String.format("Upgrading schema from version %s to version %s", currentVersion, newVersion)); if (CollectionUtils.isNotEmpty(statements)) { int executedOk = 0; int totalUpdatedRows = 0; for (final String statement : statements) { try { final int updatedRows = jdbc.execute(statement); LOG.info(String.format("Statement executed: %s", statement)); if (updatedRows > 0) { LOG.info("Updated rows: " + updatedRows); totalUpdatedRows += updatedRows; } executedOk++; } catch (final SQLException e) { LOG.warn(String.format("Error applying automatic schema upgrade on version %s when executing statement [%s]: %s", newVersion, statement, e.getMessage())); } } String msg = "Statements executed (ok / total): " + executedOk + " / " + statements.size() + "."; if (totalUpdatedRows == 0) { msg += " None of the executed statements has modified data."; } LOG.info(msg); } LOG.info("Executing migrations..."); // Apply the migrations try { final List<Class<Migration>> migrations = version.getMigrations(databaseName); if (CollectionUtils.isNotEmpty(migrations)) { int executedOk = 0; int totalUpdatedRows = 0; for (final Class<Migration> clazz : migrations) { LOG.info(String.format("Executing migration class %s", clazz.getName())); Migration migration; try { migration = clazz.newInstance(); } catch (final Exception e) { LOG.warn(String.format("Error instantiating the migration class %s", clazz.getName()), e); continue; } try { if (migration instanceof UntraceableMigration) { ((UntraceableMigration) migration).execute(jdbc); } else {// (TraceableMigration) final int updatedRows = ((TraceableMigration) migration).execute(jdbc); if (updatedRows > 0) { LOG.info("Updated rows: " + updatedRows); totalUpdatedRows += updatedRows; } } executedOk++; } catch (final Exception e) { LOG.warn(String.format("Error upgrading to version %s when executing migration: %s", newVersion, e.getMessage())); LOG.debug(e); } } String msg = "Migrations executed (ok / total): " + executedOk + " / " + migrations.size() + "."; if (totalUpdatedRows == 0) { msg += " None of the executed migrations has modified data."; } LOG.info(msg); } // Perform a commit try { jdbc.commit(); } catch (final SQLException e) { LOG.warn("Error while committing", e); } } catch (final Exception e) { try { jdbc.rollback(); } catch (final SQLException e1) { LOG.warn("Error while rolling back", e1); } } // Remove the unused translation messages final List<String> removedTranslationKeys = version.getRemovedTranslationKeys(); if (CollectionUtils.isNotEmpty(removedTranslationKeys)) { for (String key : removedTranslationKeys) { key = StringUtils.trimToEmpty(key); try { final int deletedRows = jdbc.execute("delete from translation_messages where msg_key = ?", key); if (deletedRows > 0) { LOG.info(String.format("Removing unused translation message: %s", key)); } else { LOG.info(String.format("Unused translation message: %s was not found in the database", key)); } } catch (final Exception e) { // Keeping an unused translation message is not that serious... Move on ;-) LOG.warn(String.format("Error removing unused translation message: %s", key), e); } } try { jdbc.commit(); } catch (final SQLException e) { LOG.warn("Error while committing", e); } } // Update the version currentVersion = newVersion; try { jdbc.execute("update application set version = ?", currentVersion); jdbc.commit(); } catch (final SQLException e) { LOG.warn(String.format("Error while updating the current version table to %s", currentVersion), e); try { jdbc.rollback(); } catch (final SQLException e1) { LOG.warn("Error while rolling back", e1); } } } if (!originalVersion.equals(currentVersion)) { LOG.info(String.format("The database has been upgraded to version %s", currentVersion)); } return currentVersion; } /** * Validates the connection parameters, returning an open connection */ private Connection validateConnection(final String url, final String username, final String password) { Connection connection = null; try { connection = DriverManager.getConnection(url, username, password); connection.setAutoCommit(false); } catch (final SQLException e) { final String msg = "Error connecting to database at " + url; LOG.error(msg); throw new RuntimeException(msg, e); } return connection; } /** * Validates the given JDBC driver class */ private void validateDriver(final String driverClass) { try { final Class<?> clazz = Class.forName(driverClass); if (!Driver.class.isAssignableFrom(clazz)) { throw new Exception(); } driverToUnregister = clazz; } catch (final Exception e) { final String msg = "Illegal JDBC driver class on cyclos.properties: " + driverClass; LOG.error(msg); throw new RuntimeException(msg); } } private void warnTrailingSpaces(final Properties properties) { for (String key : properties.stringPropertyNames()) { String value = properties.getProperty(key); if (!key.equals("line.separator") && !value.trim().equals(value)) { LOG.warn("Property '" + key + "' has trailing spaces. Its value is : '" + value + "'"); } } } }