xand.es

Liquibase context control

When using liquibase for database versioning it is very common to use the same approach to load test data. For example, I use it to load data on development (developer's machines) and staging environment. But, in any circumstances, the test data should not be loaded in any real environment (production).

This behavior may be achieved using liquibase contexts.

Imagine the following changesets, for example:


    <!-- this changeset must only be executed in dev,staging environments -->
    <changeSet id="1566987495224" author="developer@example.com" context="dev,staging">
        <insert tableName="ov_emp_rel_employee_attribute">
            <column name="employee_ref_id" value="9815160b-de4e-434b-9052-b54b357892db"/>
            <column name="attribute_ref_id" value="f5f00fe2-281e-4d5b-b324-82604b5a5009"/>
            <column name="value" value="pperez@example.com"/>
            <column name="creation_date" valueComputed="CURRENT_TIMESTAMP"/>
            <column name="updated_date" valueComputed="CURRENT_TIMESTAMP"/>
        </insert>
    </changeSet>

    <!-- this changeset must always be executed -->
    <changeSet id="1566987495225" author="developer@example.com">
        <addColumn tableName="ov_dst_edition">
            <column name="status" type="varchar(10)"/>
            <column name="init_date" type="timestamp"/>
            <column name="end_date" type="timestamp"/>
        </addColumn>
    </changeSet>

In this situation if the developer forgets to indicate the context (first change request in the example), this changes will go into production environment. In order to avoid this situation I prepared a simple unit test which perform the following checks:

  • There are no duplicate ids in the liquibase xml files. This is not strictly necessary, but I prefer to have unique ids.
  • All liquibase statements capable of changing data must include the context information. See OPS_NOT_ALLOWED_WO_CONTEXT array. The elements are following: insert, update, delete, sqlFile, sql, mergeColumns, loadUpdateData, loadData, executeCommand, customChange.
  • Sometimes there are exceptions to the above rule (for example, a catalog table), in which case the change request must be manually whitelisted (see ALLOWED_CHANGESETS array).

The unit test is the following:


package net.overlap.integration;

import org.apache.commons.io.FileUtils;
import org.dom4j.Document;
import org.dom4j.Element;
import org.dom4j.Node;
import org.dom4j.io.DOMReader;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.File;
import java.net.URL;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;

@RunWith(JUnit4.class)
public class LiquibaseContextTest {

    private static final String[] EXCLUDED_FILES = {"changelog.xml"};
    private static final String[] OPS_NOT_ALLOWED_WO_CONTEXT = {
            "insert", "update", "delete", "sqlFile", "sql", "mergeColumns",
            "loadUpdateData", "loadData", "executeCommand", "customChange"
    };

    private static final String[] ALLOWED_CHANGESETS = {
            "1562773293100", "1562773293104"
    };

    @Test
    public void testPolicyViolation() throws Exception {
        List changesets = this.load();

        boolean policyViolation = false;
        for(ChangesetDTO c : changesets) {
            boolean v = c.violatesPolicy(OPS_NOT_ALLOWED_WO_CONTEXT);
            System.out.println(c.toString() + " -> "
                    + String.valueOf(v));
            if(v) {
                if(Arrays.binarySearch(ALLOWED_CHANGESETS, c.id) < 0) {
                    policyViolation = true;

                    System.out.println(c.file + ":" + c.id + " -> "
                            + "CONTEXT POLICY VIOLATION");
                }
            }
        }
        Assert.assertFalse(policyViolation);
    }

    @Test
    public void testDuplicates() throws Exception {
        List changesets = this.load();

        boolean duplicates = hasDuplicateChangesets(changesets);
        Assert.assertFalse(duplicates);
    }

    private List load() throws Exception {
        // list all files in /database
        URL url = Paths.get("target").toUri().toURL();
        String sUrl = url.toString();
        String sPath = sUrl.substring("file:".length());
        sPath += "classes";
        sPath += File.separator;
        sPath += "database";

        List filesToCheck = new ArrayList<>();

        Collection files = FileUtils.listFiles(new File(sPath), new String[]{"xml"}, false);
        for(File f : files) {
            String filename = f.getName();
            if(Arrays.binarySearch(EXCLUDED_FILES, filename) == -1) {
                filesToCheck.add(filename);
            }
        }

        String[] arrFilesToCheck = new String[filesToCheck.size()];
        filesToCheck.toArray(arrFilesToCheck);

        Arrays.sort(arrFilesToCheck);

        List changesets = new ArrayList<>();

        for(String f : arrFilesToCheck) {
            String fullPath = sPath + File.separator + f;
            System.out.println("Checking file: " + fullPath);

            File xmlFile = new File(fullPath);
            DocumentBuilderFactory dbf = DocumentBuilderFactory.newDefaultInstance();
            DocumentBuilder dBuilder = dbf.newDocumentBuilder();
            org.w3c.dom.Document doc = dBuilder.parse(xmlFile);

            DOMReader domReader = new DOMReader();
            Document document = domReader.read(doc);

            this.processSingleFile(document, changesets, f);
        }

        return changesets;
    }

    private boolean hasDuplicateChangesets(List changesets) {
        List checkList = new ArrayList<>();

        boolean output = false;

        for(ChangesetDTO c : changesets) {
            if(checkList.contains(c.id)) {
                output = true;
                System.out.println("Detected duplicate changeset: " + c.id);
            } else {
                checkList.add(c.id);
            }
        }

        return output;
    }

    private void processSingleFile(Document document,
                                   List changesets,
                                   String file) {

        List nodes = document.selectNodes("//databaseChangeLog/changeSet");

        for(int i = 0; i < nodes.size(); i++) {
            Node node = (Node)nodes.get(i);
            Element elem = (Element)nodes.get(i);

            String changeSetId = elem.attributeValue("id");
            String context = elem.attributeValue("context");

            ChangesetDTO dto = new ChangesetDTO();
            dto.file = file;
            dto.id = changeSetId;

            if(context != null) {
                dto.hasContext = true;
            } else {
                dto.hasContext = false;
            }

            List subNodes = node.selectNodes("*");
            for(int j = 0; j < subNodes.size(); j++) {
                Element subElement = (Element)subNodes.get(j);

                String subElementName = subElement.getName();
                if(!dto.operations.contains(subElementName)) {
                    dto.operations.add(subElementName);
                }

            }

            changesets.add(dto);
        }
    }

}

As you may see, if this unit test fails (there is policy violation) the aplication will not compile when building with maven.