All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.minijax.liquibase.LiquibaseHelper Maven / Gradle / Ivy

There is a newer version: 0.5.10
Show newest version
package org.minijax.liquibase;

import static java.util.Collections.*;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathException;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;

import org.minijax.commons.MinijaxProperties;
import org.minijax.commons.XmlUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import liquibase.Contexts;
import liquibase.LabelExpression;
import liquibase.Liquibase;
import liquibase.change.Change;
import liquibase.change.core.DropTableChange;
import liquibase.changelog.ChangeSet;
import liquibase.database.Database;
import liquibase.database.DatabaseFactory;
import liquibase.database.jvm.JdbcConnection;
import liquibase.diff.DiffGeneratorFactory;
import liquibase.diff.DiffResult;
import liquibase.diff.compare.CompareControl;
import liquibase.diff.output.DiffOutputControl;
import liquibase.diff.output.changelog.DiffToChangeLog;
import liquibase.exception.DatabaseException;
import liquibase.exception.LiquibaseException;
import liquibase.resource.ClassLoaderResourceAccessor;
import liquibase.resource.ResourceAccessor;
import liquibase.serializer.core.xml.XMLChangeLogSerializer;
import liquibase.snapshot.SnapshotControl;

/**
 * The LiquibaseHelper class provides helpers to migrate a database and generate new migrations.
 *
 * It is a thin layer that wraps JPA and Liquibase.
 *
 * By default, migrations are added to src/main/resources/changelog.xml.
 *
 * The recommended workflow is to (1) generate a migration, (2) manually inspect the migration,
 * and (3) run the migration if everything looks ok.
 */
public class LiquibaseHelper {
    private static final Logger LOG = LoggerFactory.getLogger(LiquibaseHelper.class);
    private static final File DEFAULT_RESOURCES_DIR = new File("src/main/resources");
    private static final String MIGRATIONS_DIR = "migrations";
    private static final String MASTER_CHANGELOG_RESOURCE_NAME = "master.changelog.xml";
    private final String persistenceUnitName;
    private final String driver;
    private final String url;
    private final String username;
    private final String password;
    private final String referenceUrl;
    private final ResourceAccessor resourceAccessor;
    private final File resourcesDir;
    private final File migrationsDir;
    private final File masterChangeLogFile;

    /**
     * Creates a new helper.
     *
     * @param props
     */
    public LiquibaseHelper(final Map props) {
        this(
                props,
                new ClassLoaderResourceAccessor(),
                DEFAULT_RESOURCES_DIR,
                MASTER_CHANGELOG_RESOURCE_NAME);
    }

    LiquibaseHelper(
            final Map props,
            final ResourceAccessor resourceAccessor,
            final File resourcesDir,
            final String masterChangeLogName) {

        this.resourceAccessor = resourceAccessor;
        this.resourcesDir = resourcesDir;

        persistenceUnitName = props.get(MinijaxProperties.PERSISTENCE_UNIT_NAME);
        driver = props.get(MinijaxProperties.DB_DRIVER);
        url = props.get(MinijaxProperties.DB_URL);
        username = props.get(MinijaxProperties.DB_USERNAME);
        password = props.get(MinijaxProperties.DB_PASSWORD);
        referenceUrl = props.get(MinijaxProperties.DB_REFERENCE_URL);
        migrationsDir = new File(resourcesDir, MIGRATIONS_DIR);
        masterChangeLogFile = new File(migrationsDir, masterChangeLogName);
    }

    public File getResourcesDir() {
        return resourcesDir;
    }

    public File getMasterChangeLogFile() {
        return masterChangeLogFile;
    }

    /**
     * Helper utility to perform a Liquibase database migration.
     *
     * @see liquibase.Liquibase#update(String)
     */
    public void migrate() throws LiquibaseException, SQLException {
        Database database = null;

        try {
            database = getTargetDatabase();
            getLiquibase(database).update(""); // Empty string = all contexts

        } finally {
            closeQuietly(database);
        }
    }

    public File generateMigrations() throws IOException, LiquibaseException, SQLException {
        Database referenceDatabase = null;
        Database targetDatabase = null;

        try {
            referenceDatabase = getReferenceDatabase();
            targetDatabase = getTargetDatabase();
            return generateMigrations(referenceDatabase, targetDatabase);

        } finally {
            closeQuietly(referenceDatabase);
            closeQuietly(targetDatabase);
        }
    }

    /*
     * Private helpers
     */

    private Liquibase getLiquibase(final Database targetDatabase) {
        return new Liquibase(getRelativePath(masterChangeLogFile), resourceAccessor, targetDatabase);
    }

    private String getRelativePath(final File resourceFile) {
        return resourcesDir.toPath().relativize(resourceFile.toPath()).toString();
    }

    /**
     * Returns a database connection.
     *
     * Override this method in tests to avoid actually connecting to databases.
     *
     * @param url
     * @param username
     * @param password
     * @return
     */
    private Connection getConnection(final String url, final String username, final String password) throws SQLException {
        return DriverManager.getConnection(url, username, password);
    }

    private Database getLiquibaseDatabase(final Connection conn) throws DatabaseException {
        return DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(conn));
    }

    private Database getTargetDatabase() throws LiquibaseException, SQLException {
        try {
            Class.forName(driver);
        } catch (final ClassNotFoundException ex) {
            throw new LiquibaseException(ex.getMessage(), ex);
        }
        return getLiquibaseDatabase(getConnection(url, username, password));
    }

    /**
     * Returns the "reference" database.
     *
     * In Liquibase terminology, this is the destination / goal state.
     *
     * We create an empty temporary database, and use JPA to autogenerate the schema.
     *
     * @return Database connection to the temporary reference database.
     */
    private Database getReferenceDatabase() throws DatabaseException, SQLException {
        buildReferenceDatabase();
        return getLiquibaseDatabase(getConnection(referenceUrl, username, password));
    }

    private void buildReferenceDatabase() {
        final Map props = new HashMap<>();
        props.put(MinijaxProperties.DB_DRIVER, driver);
        props.put(MinijaxProperties.DB_URL, referenceUrl);
        props.put(MinijaxProperties.DB_USERNAME, username);
        props.put(MinijaxProperties.DB_PASSWORD, password);
        props.put("jakarta.persistence.schema-generation.database.action", "drop-and-create");

        EntityManagerFactory emf = null;
        try {
            emf = Persistence.createEntityManagerFactory(persistenceUnitName, props);
        } finally {
            closeQuietly(emf);
        }
    }

    private File generateMigrations(final Database referenceDatabase, final Database targetDatabase)
            throws LiquibaseException, IOException {

        if (!resourcesDir.exists()) {
            resourcesDir.mkdirs();
        }

        if (!migrationsDir.exists()) {
            migrationsDir.mkdirs();
        }

        if (masterChangeLogFile.exists()) {
            LOG.info("Checking current database state");
            validateDatabaseState(targetDatabase);

        } else {
            LOG.info("Creating new master changelog");
            writeChangeSets(masterChangeLogFile, emptyList());
        }

        @SuppressWarnings("unchecked")
        final SnapshotControl snapshotControl = new SnapshotControl(
                referenceDatabase,
                liquibase.structure.core.Schema.class,
                liquibase.structure.core.Table.class,
                liquibase.structure.core.Column.class,
                liquibase.structure.core.PrimaryKey.class,
                liquibase.structure.core.Index.class);

        LOG.info("Executing diff");
        final CompareControl compareControl = new CompareControl(snapshotControl.getTypesToInclude());
        final DiffResult diffResult = DiffGeneratorFactory.getInstance().compare(
                referenceDatabase,
                targetDatabase,
                compareControl);

        LOG.info("Converting diff to changelog");
        final DiffOutputControl diffOutputControl = new DiffOutputControl(false, false, true, null);
        final DiffToChangeLog diffToChangeLog = new DiffToChangeLog(diffResult, diffOutputControl);
        diffToChangeLog.setChangeSetAuthor(System.getProperty("user.name"));

        final List changeSets = filterChangeSets(diffToChangeLog.generateChangeSets());
        LOG.info("Found {} changes", changeSets.size());
        if (changeSets.isEmpty()) {
            return null;
        }

        final File generatedChangeLogFile = new File(migrationsDir, generateFileName(masterChangeLogFile));
        LOG.info("Writing new changelog: {}", generatedChangeLogFile);
        writeChangeSets(generatedChangeLogFile, changeSets);

        LOG.info("Add migration to master changelog: {}", masterChangeLogFile);
        addIncludeFile(generatedChangeLogFile);

        LOG.info("Cleaning changelog");
        cleanXmlFile(masterChangeLogFile);
        cleanXmlFile(generatedChangeLogFile);

        LOG.info("Diff complete");
        return generatedChangeLogFile;
    }

    /**
     * Validates that the database is in a good state.
     *
     * @param database The database.
     * @param fileName The change log file name.
     * @param resourceAccessor The change log file loader.
     */
    private void validateDatabaseState(final Database database) throws LiquibaseException {
        final Liquibase liquibase = getLiquibase(database);
        final Contexts contexts = new Contexts(); // all contexts
        final LabelExpression labels = new LabelExpression(); // no filters
        final List unrunChangeSets = liquibase.listUnrunChangeSets(contexts, labels);
        if (!unrunChangeSets.isEmpty()) {
            throw new IllegalStateException("Unrun change sets!  Please migrate the database first");
        }
    }

    /**
     * Filters out all ignored change sets.
     *
     * @param changeSets The original list of change sets.
     * @return The filtered list of change sets.
     */
    static List filterChangeSets(final List changeSets) {
        final List result = new ArrayList<>();
        for (final ChangeSet changeSet : changeSets) {
            if (!isIgnoredChangeSet(changeSet)) {
                result.add(changeSet);
            }
        }
        return result;
    }

    /**
     * Returns true if the change set should be ignored.
     *
     * Most tables are managed by JPA.  When we create the temporary "goal" database,
     * JPA creates all of the tables for a normal happy path diff.
     *
     * If using JGroups, there is an extra table created called "JGROUPSPING" which
     * tracks all of the members of the JGroups cluster.  Because this table is not
     * managed by JPA, it is absent from the "goal" database.  Therefore liquibase
     * always tries to drop the table.  We need to ignore that.
     *
     * @param changeSet The candidate change set.
     * @return True if the change set should be ignored.
     */
    static boolean isIgnoredChangeSet(final ChangeSet changeSet) {
        final List changes = changeSet.getChanges();
        if (changes.size() != 1) {
            return false;
        }

        final Change change = changes.get(0);
        if (!(change instanceof DropTableChange)) {
            return false;
        }

        return ((DropTableChange) change).getTableName().equals("JGROUPSPING");
    }

    static void closeQuietly(final EntityManagerFactory emf) {
        if (emf != null) {
            try {
                emf.close();
            } catch (final Exception ex) {
                LOG.warn("Error closing entity manager factory: {}", ex.getMessage(), ex);
            }
        }
    }

    static void closeQuietly(final Database database) {
        if (database != null) {
            try {
                database.close();
            } catch (final Exception ex) {
                LOG.warn("Error closing database: {}", ex.getMessage(), ex);
            }
        }
    }

    private void writeChangeSets(final File file, final List changeSets) throws IOException {
        try (final FileOutputStream outputStream = new FileOutputStream(file)) {
            final XMLChangeLogSerializer changeLogSerializer = new XMLChangeLogSerializer();
            changeLogSerializer.write(changeSets, outputStream);
            outputStream.flush();
        }
    }

    private void addIncludeFile(final File includeFile) throws IOException {
        final Document doc = XmlUtils.readXml(masterChangeLogFile);
        final Element include = doc.createElement("include");
        include.setAttribute("file", getRelativePath(includeFile));
        doc.getDocumentElement().appendChild(include);
        XmlUtils.writeXml(doc, masterChangeLogFile);
    }

    /**
     * Cleans the XML file.
     *  1) Updates formatting and indentation.
     *  2) Removes liquibase "objectQuotingStrategy" attributes.
     *
     * @param file The changelog file.
     */
    private static void cleanXmlFile(final File file) throws IOException {
        try {
            final Document doc = XmlUtils.readXml(file);
            final XPath xpath = XPathFactory.newInstance().newXPath();
            removeNodes(doc, xpath, "//@objectQuotingStrategy");
            removeNodes(doc, xpath, "//text()[normalize-space()='']");
            XmlUtils.writeXml(doc, file);
        } catch (final XPathException ex) {
            throw new IOException(ex.getMessage(), ex);
        }
    }

    /**
     * Removes all nodes that match the XPath expression.
     *
     * @param doc The XML document.
     * @param xpath The XPath helper.
     * @param expression The XPath expression.
     */
    private static void removeNodes(final Document doc, final XPath xpath, final String expression)
            throws XPathExpressionException {

        final NodeList nodeList = (NodeList) xpath.evaluate(expression, doc, XPathConstants.NODESET);
        for (int i = 0; i < nodeList.getLength(); ++i) {
            final Node node = nodeList.item(i);
            if (node instanceof Attr) {
                final Attr attr = (Attr) node;
                attr.getOwnerElement().removeAttribute(attr.getNodeName());
            } else {
                node.getParentNode().removeChild(node);
            }
        }
    }

    private static String generateFileName(final File masterChangeLogFile) throws IOException {
        final int id = XmlUtils.readXml(masterChangeLogFile).getDocumentElement().getElementsByTagName("include").getLength() + 1;
        return String.format("changelog.%04d.xml", id);
    }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy