001// Copyright 2011 Leo Przybylski. All rights reserved.
002//
003// Redistribution and use in source and binary forms, with or without modification, are
004// permitted provided that the following conditions are met:
005//
006//    1. Redistributions of source code must retain the above copyright notice, this list of
007//       conditions and the following disclaimer.
008//
009//    2. Redistributions in binary form must reproduce the above copyright notice, this list
010//       of conditions and the following disclaimer in the documentation and/or other materials
011//       provided with the distribution.
012//
013// THIS SOFTWARE IS PROVIDED BY <COPYRIGHT HOLDER> ''AS IS'' AND ANY EXPRESS OR IMPLIED
014// WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
015// FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL <COPYRIGHT HOLDER> OR
016// CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
017// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
018// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
019// ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
020// NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
021// ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
022//
023// The views and conclusions contained in the software and documentation are those of the
024// authors and should not be interpreted as representing official policies, either expressed
025// or implied, of Leo Przybylski.
026package org.kualigan.maven.plugins.liquibase;
027
028import org.apache.maven.plugin.AbstractMojo;
029import org.apache.maven.plugin.MojoExecutionException;
030import org.apache.maven.plugin.MojoFailureException;
031import org.apache.maven.plugins.annotations.Component;
032import org.apache.maven.plugins.annotations.Mojo;
033import org.apache.maven.plugins.annotations.Parameter;
034import org.apache.maven.project.MavenProject;
035import org.apache.maven.artifact.manager.WagonManager;
036
037import org.liquibase.maven.plugins.AbstractLiquibaseMojo;
038import org.liquibase.maven.plugins.AbstractLiquibaseChangeLogMojo;
039import org.liquibase.maven.plugins.MavenUtils;
040
041import liquibase.Liquibase;
042import liquibase.exception.LiquibaseException;
043import liquibase.serializer.ChangeLogSerializer;
044import liquibase.parser.core.xml.LiquibaseEntityResolver;
045import liquibase.parser.core.xml.XMLChangeLogSAXParser;
046import org.apache.maven.wagon.authentication.AuthenticationInfo;
047
048import liquibase.util.xml.DefaultXmlWriter;
049
050import org.w3c.dom.*;
051
052import javax.xml.parsers.DocumentBuilder;
053import javax.xml.parsers.DocumentBuilderFactory;
054import javax.xml.parsers.ParserConfigurationException;
055
056
057import java.lang.reflect.Field;
058import java.lang.reflect.Method;
059import java.io.File;
060import java.io.FilenameFilter;
061import java.io.FileNotFoundException;
062import java.io.FileOutputStream;
063import java.io.InputStream;
064import java.io.IOException;
065import java.net.URL;
066import java.util.ArrayList;
067import java.util.Arrays;
068import java.util.Collection;
069import java.util.Iterator;
070import java.util.List;
071import java.util.Properties;
072
073/**
074 * Tests Liquibase changelogs against various databases
075 *
076 * @author Leo Przybylski
077 * @goal test
078 */
079public class LiquibaseTestMojo extends AbstractLiquibaseChangeLogMojo {
080    public static final String DEFAULT_CHANGELOG_PATH = "src/main/changelogs";
081    public static final String DEFAULT_UPDATE_FILE    = "target/changelogs/update.xml";
082    public static final String DEFAULT_UPDATE_PATH    = "target/changelogs/update";
083    public static final String DEFAULT_LBPROP_PATH    = "target/test-classes/liquibase/";
084    public static final String TEST_ROLLBACK_TAG      = "test";
085
086    /**
087     * Suffix for fields that are representing a default value for a another field.
088     */
089    private static final String DEFAULT_FIELD_SUFFIX = "Default";
090
091    /**
092     * The fully qualified name of the driver class to use to connect to the database.
093     */
094    @Parameter(property = "liquibase.driver", required = true)
095    protected String driver;
096
097    /**
098     * The Database URL to connect to for executing Liquibase.
099     */
100    @Parameter(property = "liquibase.url", required = true)
101    protected String url;
102
103    /**
104     * 
105     * The Maven Wagon manager to use when obtaining server authentication details.
106     */
107    @Component(role=org.apache.maven.artifact.manager.WagonManager.class)
108    protected WagonManager wagonManager;
109
110    /**
111     * The server id in settings.xml to use when authenticating with.
112     */
113    @Parameter(property = "liquibase.server", required = true)
114    private String server;
115
116    /**
117     * The database username to use to connect to the specified database.
118     */
119    @Parameter(property = "liquibase.username", required = true)
120    protected String username;
121
122    /**
123     * The database password to use to connect to the specified database.
124     */
125    @Parameter(property = "liquibase.password", required = true)
126    protected String password;
127    
128    /**
129     * The default schema name to use the for database connection.
130     */
131    @Parameter(property = "liquibase.defaultSchemaName")
132    protected String defaultSchemaName;
133
134    /**
135     * The class to use as the database object.
136     */
137    @Parameter(property = "liquibase.databaseClass", required = true)
138    protected String databaseClass;
139
140    /**
141     * Controls the prompting of users as to whether or not they really want to run the
142     * changes on a database that is not local to the machine that the user is current
143     * executing the plugin on.
144     *
145     * @parameter expression="${liquibase.promptOnNonLocalDatabase}" default-value="false"
146     */
147    protected boolean promptOnNonLocalDatabase;
148
149    /**
150     * Allows for the maven project artifact to be included in the class loader for
151     * obtaining the Liquibase property and DatabaseChangeLog files.
152     *
153     * @parameter expression="${liquibase.includeArtifact}" default-value="true"
154     */
155    protected boolean includeArtifact;
156
157    /**
158     * Allows for the maven test output directory to be included in the class loader for
159     * obtaining the Liquibase property and DatabaseChangeLog files.
160     *
161     * @parameter expression="${liquibase.includeTestOutputDirectory}" default-value="true"
162     */
163    protected boolean includeTestOutputDirectory;
164
165    /**
166     * Controls the verbosity of the output from invoking the plugin.
167     *
168     * @parameter expression="${liquibase.verbose}" default-value="false"
169     * @description Controls the verbosity of the plugin when executing
170     */
171    protected boolean verbose;
172
173    /**
174     * Controls the level of logging from Liquibase when executing. The value can be
175     * "all", "finest", "finer", "fine", "info", "warning", "severe" or "off". The value is
176     * case insensitive.
177     *
178     * @parameter expression="${liquibase.logging}" default-value="INFO"
179     * @description Controls the verbosity of the plugin when executing
180     */
181    protected String logging;
182
183    /**
184     * The Liquibase properties file used to configure the Liquibase {@link
185     * liquibase.Liquibase}.
186     *
187     * @parameter expression="${liquibase.propertyFile}"
188     */
189    protected String propertyFile;
190
191    /**
192     * Flag allowing for the Liquibase properties file to override any settings provided in
193     * the Maven plugin configuration. By default if a property is explicity specified it is
194     * not overridden if it also appears in the properties file.
195     *
196     * @parameter expression="${liquibase.propertyFileWillOverride}" default-value="true"
197     */
198    protected boolean propertyFileWillOverride;
199
200    /**
201     * Flag for forcing the checksums to be cleared from teh DatabaseChangeLog table.
202     *
203     * @parameter expression="${liquibase.clearCheckSums}" default-value="false"
204     */
205    protected boolean clearCheckSums;
206
207    /**                                                                                                                                                                          
208     * List of system properties to pass to the database.                                                                                                                        
209     *                                                                                                                                                                           
210     * @parameter                                                                                                                                                                
211     */                                                                                                                                                                          
212    protected Properties systemProperties;
213
214    /**
215     * Specifies the change log file to use for Liquibase. No longer needed with updatePath.
216     * @parameter expression="${liquibase.changeLogFile}"
217     * @deprecated
218     */
219    protected String changeLogFile;
220
221    /**
222     * @parameter default-value="${project.basedir}/target/changelogs"
223     */
224    protected File changeLogSavePath;
225
226    /**
227     * Location of an update.xml
228     */
229    @Parameter(property = "lb.updatePath", defaultValue="${project.basedir}/src/main/changelogs")
230    protected File updatePath;
231    
232    /**
233     * The tag to roll the database back to. 
234     */
235    @Parameter(property = "liquibase.rollbackTag")
236    protected String rollbackTag;
237    
238    /**
239     * The Maven project that plugin is running under.
240     */
241    @Parameter(property = "project", required = true)
242    protected MavenProject project;
243
244
245    /**
246     * The Liquibase contexts to execute, which can be "," separated if multiple contexts
247     * are required. If no context is specified then ALL contexts will be executed.
248     * @parameter expression="${liquibase.contexts}" default-value=""
249     */
250    protected String contexts;
251
252    protected File getBasedir() {
253        return project.getBasedir();
254    }
255
256    protected void doFieldHack() {
257        for (final Field field : getClass().getDeclaredFields()) {
258            try {
259                final Field parentField = getDeclaredField(getClass().getSuperclass(), field.getName());
260                if (parentField != null) {
261                    getLog().debug("Setting " + field.getName() + " in " + parentField.getDeclaringClass().getName() + " to " + field.get(this));
262                    parentField.set(this, field.get(this));
263                }
264            }
265            catch (Exception e) {
266            }
267        }
268    }
269
270    protected File[] getLiquibasePropertiesFiles() throws MojoExecutionException {
271        try {
272            final File[] retval = new File(getBasedir(), DEFAULT_LBPROP_PATH).listFiles(new FilenameFilter() {
273                    public boolean accept(final File dir, final String name) {
274                        return name.endsWith(".properties");
275                    }
276                });
277            if (retval == null) {
278                throw new NullPointerException();
279            }            
280            return retval;
281        }
282        catch (Exception e) {
283            getLog().warn("Unable to get liquibase properties files ");
284            return new File[0];
285            // throw new MojoExecutionException("Unable to get liquibase properties files ", e);
286        }
287    }
288
289    @Override
290    public void execute() throws MojoExecutionException, MojoFailureException {
291        changeLogFile = DEFAULT_UPDATE_FILE;
292        try {
293            Method meth = AbstractLiquibaseMojo.class.getDeclaredMethod("processSystemProperties");
294            meth.setAccessible(true);
295            meth.invoke(this);
296        }
297        catch (Exception e) {
298            e.printStackTrace();
299        }
300        super.project = this.project;
301
302        ClassLoader artifactClassLoader = getMavenArtifactClassLoader();
303        final File[] propertyFiles = getLiquibasePropertiesFiles();
304        
305        // execute change logs on each database
306        for (final File props : propertyFiles) {
307            try {
308                propertyFile = props.getCanonicalPath();
309                doFieldHack();
310                
311                configureFieldsAndValues(getFileOpener(artifactClassLoader));
312                
313                doFieldHack();
314            }
315            catch (Exception e) {
316                throw new MojoExecutionException(e.getMessage(), e);
317            }
318            
319            super.execute();
320        }
321    }
322
323    @Override
324    protected void performLiquibaseTask(Liquibase liquibase) throws LiquibaseException {
325        super.performLiquibaseTask(liquibase);
326        
327        getLog().info("Tagging the database");
328        rollbackTag = TEST_ROLLBACK_TAG;
329        liquibase.tag(rollbackTag);
330
331        final File realChangeLogFile = new File(changeLogFile);
332        // If there isn't an update.xml, then make one       
333        if (!realChangeLogFile.exists()) {
334            try {
335                final Collection<File> changelogs = scanForChangelogs(changeLogSavePath);
336                generateUpdateLog(realChangeLogFile, changelogs);
337            }
338            catch (Exception e) {
339                throw new LiquibaseException(e);
340            }
341        }
342
343        getLog().info("Doing update");
344        liquibase.update(contexts);
345
346        getLog().info("Doing rollback");
347        liquibase.rollback(rollbackTag, contexts);
348    }
349
350    /**
351     * Parses a properties file and sets the assocaited fields in the plugin.
352     *
353     * @param propertiesInputStream The input stream which is the Liquibase properties that
354     *                              needs to be parsed.
355     * @throws org.apache.maven.plugin.MojoExecutionException
356     *          If there is a problem parsing
357     *          the file.
358     */
359    protected void parsePropertiesFile(InputStream propertiesInputStream)
360            throws MojoExecutionException {
361        if (propertiesInputStream == null) {
362            throw new MojoExecutionException("Properties file InputStream is null.");
363        }
364        Properties props = new Properties();
365        try {
366            props.load(propertiesInputStream);
367        }
368        catch (IOException e) {
369            throw new MojoExecutionException("Could not load the properties Liquibase file", e);
370        }
371
372        for (Iterator it = props.keySet().iterator(); it.hasNext();) {
373            String key = null;
374            try {
375                key = (String) it.next();
376                Field field = getDeclaredField(this.getClass(), key);
377
378                if (propertyFileWillOverride) {
379                    setFieldValue(field, props.get(key).toString());
380                } else {
381                    if (!isCurrentFieldValueSpecified(field)) {
382                        getLog().debug("  properties file setting value: " + field.getName());
383                        setFieldValue(field, props.get(key).toString());
384                    }
385                }
386            }
387            catch (Exception e) {
388                getLog().info("  '" + key + "' in properties file is not being used by this "
389                        + "task.");
390            }
391        }
392    }
393
394    /**
395     * This method will check to see if the user has specified a value different to that of
396     * the default value. This is not an ideal solution, but should cover most situations in
397     * the use of the plugin.
398     *
399     * @param f The Field to check if a user has specified a value for.
400     * @return <code>true</code> if the user has specified a value.
401     */
402    private boolean isCurrentFieldValueSpecified(Field f) throws IllegalAccessException {
403        Object currentValue = f.get(this);
404        if (currentValue == null) {
405            return false;
406        }
407
408        Object defaultValue = getDefaultValue(f);
409        if (defaultValue == null) {
410            return currentValue != null;
411        } else {
412            // There is a default value, check to see if the user has selected something other
413            // than the default
414            return !defaultValue.equals(f.get(this));
415        }
416    }
417
418    private Object getDefaultValue(Field field) throws IllegalAccessException {
419        List<Field> allFields = new ArrayList<Field>();
420        allFields.addAll(Arrays.asList(getClass().getDeclaredFields()));
421        allFields.addAll(Arrays.asList(AbstractLiquibaseMojo.class.getDeclaredFields()));
422
423        for (Field f : allFields) {
424            if (f.getName().equals(field.getName() + DEFAULT_FIELD_SUFFIX)) {
425                f.setAccessible(true);
426                return f.get(this);
427            }
428        }
429        return null;
430    }
431
432    
433    /**
434     * Recursively searches for the field specified by the fieldName in the class and all
435     * the super classes until it either finds it, or runs out of parents.
436     * @param clazz The Class to start searching from.
437     * @param fieldName The name of the field to retrieve.
438     * @return The {@link Field} identified by the field name.
439     * @throws NoSuchFieldException If the field was not found in the class or any of its
440     * super classes.
441     */
442    protected Field getDeclaredField(Class clazz, String fieldName)
443        throws NoSuchFieldException {
444        getLog().debug("Checking " + clazz.getName() + " for '" + fieldName + "'");
445        try {
446            Field f = clazz.getDeclaredField(fieldName);
447            
448            if (f != null) {
449                return f;
450            }
451        }
452        catch (Exception e) {
453        }
454        
455        while (clazz.getSuperclass() != null) {        
456            clazz = clazz.getSuperclass();
457            getLog().debug("Checking " + clazz.getName() + " for '" + fieldName + "'");
458            try {
459                Field f = clazz.getDeclaredField(fieldName);
460                
461                if (f != null) {
462                    return f;
463                }
464            }
465            catch (Exception e) {
466            }
467        }
468
469        throw new NoSuchFieldException("The field '" + fieldName + "' could not be "
470                                       + "found in the class of any of its parent "
471                                       + "classes.");
472    }
473
474    private void setFieldValue(Field field, String value) throws IllegalAccessException {
475        if (field.getType().equals(Boolean.class) || field.getType().equals(boolean.class)) {
476            field.set(this, Boolean.valueOf(value));
477        } else {
478            field.set(this, value);
479        }
480    }
481
482    protected Collection<File> scanForChangelogs(final File searchPath) {
483        final Collection<File> retval = new ArrayList<File>();
484        
485        if (searchPath.getName().endsWith("update")) {
486            return Arrays.asList(searchPath.listFiles());
487        }
488        
489        if (searchPath.isDirectory()) {
490            for (final File file : searchPath.listFiles()) {
491                if (file.isDirectory()) {
492                    retval.addAll(scanForChangelogs(file));
493                }
494            }
495        }
496        
497        return retval;
498    }
499
500    protected void generateUpdateLog(final File changeLogFile, final Collection<File> changelogs) throws FileNotFoundException, IOException {
501        changeLogFile.getParentFile().mkdirs();
502
503        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
504                DocumentBuilder documentBuilder;
505                try {
506                        documentBuilder = factory.newDocumentBuilder();
507                }
508                catch(ParserConfigurationException e) {
509                        throw new RuntimeException(e);
510                }
511                documentBuilder.setEntityResolver(new LiquibaseEntityResolver());
512
513                Document doc = documentBuilder.newDocument();
514                Element changeLogElement = doc.createElementNS(XMLChangeLogSAXParser.getDatabaseChangeLogNameSpace(), "databaseChangeLog");
515
516                changeLogElement.setAttribute("xmlns", XMLChangeLogSAXParser.getDatabaseChangeLogNameSpace());
517                changeLogElement.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
518                changeLogElement.setAttribute("xsi:schemaLocation", "http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-"+ XMLChangeLogSAXParser.getSchemaVersion()+ ".xsd");
519
520                doc.appendChild(changeLogElement);
521
522        for (final File changelog : changelogs) {
523            doc.getDocumentElement().appendChild(includeNode(doc, changelog));
524        }
525
526        new DefaultXmlWriter().write(doc, new FileOutputStream(changeLogFile));
527    }
528
529    protected Element includeNode(final Document parentChangeLog, final File changelog) throws IOException {
530        final Element retval = parentChangeLog.createElementNS(XMLChangeLogSAXParser.getDatabaseChangeLogNameSpace(), "include");
531        retval.setAttribute("file", changelog.getCanonicalPath());
532        return retval;
533    }
534}