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}/src/main/scripts/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/scripts/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     * @parameter expression="${project}"
241     * @required
242     * @readonly
243     */
244    @Parameter(property = "project", required = true)
245    protected MavenProject project;
246
247
248    /**
249     * The Liquibase contexts to execute, which can be "," separated if multiple contexts
250     * are required. If no context is specified then ALL contexts will be executed.
251     * @parameter expression="${liquibase.contexts}" default-value=""
252     */
253    protected String contexts;
254
255    protected File getBasedir() {
256        return project.getBasedir();
257    }
258
259    protected void doFieldHack() {
260        for (final Field field : getClass().getDeclaredFields()) {
261            try {
262                final Field parentField = getDeclaredField(getClass().getSuperclass(), field.getName());
263                if (parentField != null) {
264                    getLog().debug("Setting " + field.getName() + " in " + parentField.getDeclaringClass().getName() + " to " + field.get(this));
265                    parentField.set(this, field.get(this));
266                }
267            }
268            catch (Exception e) {
269            }
270        }
271    }
272
273    protected File[] getLiquibasePropertiesFiles() throws MojoExecutionException {
274        try {
275            final File[] retval = new File(getBasedir(), DEFAULT_LBPROP_PATH).listFiles(new FilenameFilter() {
276                    public boolean accept(final File dir, final String name) {
277                        return name.endsWith(".properties");
278                    }
279                });
280            if (retval == null) {
281                throw new NullPointerException();
282            }            
283            return retval;
284        }
285        catch (Exception e) {
286            getLog().warn("Unable to get liquibase properties files ");
287            return new File[0];
288            // throw new MojoExecutionException("Unable to get liquibase properties files ", e);
289        }
290    }
291
292    @Override
293    public void execute() throws MojoExecutionException, MojoFailureException {
294        changeLogFile = DEFAULT_UPDATE_FILE;
295        try {
296            Method meth = AbstractLiquibaseMojo.class.getDeclaredMethod("processSystemProperties");
297            meth.setAccessible(true);
298            meth.invoke(this);
299        }
300        catch (Exception e) {
301            e.printStackTrace();
302        }
303        super.project = this.project;
304
305        ClassLoader artifactClassLoader = getMavenArtifactClassLoader();
306        final File[] propertyFiles = getLiquibasePropertiesFiles();
307        
308        // execute change logs on each database
309        for (final File props : propertyFiles) {
310            try {
311                propertyFile = props.getCanonicalPath();
312                doFieldHack();
313                
314                configureFieldsAndValues(getFileOpener(artifactClassLoader));
315                
316                doFieldHack();
317            }
318            catch (Exception e) {
319                throw new MojoExecutionException(e.getMessage(), e);
320            }
321            
322            super.execute();
323        }
324    }
325
326    @Override
327    protected void performLiquibaseTask(Liquibase liquibase) throws LiquibaseException {
328        super.performLiquibaseTask(liquibase);
329        
330        getLog().info("Tagging the database");
331        rollbackTag = TEST_ROLLBACK_TAG;
332        liquibase.tag(rollbackTag);
333
334        final File realChangeLogFile = new File(changeLogFile);
335        // If there isn't an update.xml, then make one       
336        if (!realChangeLogFile.exists()) {
337            try {
338                final Collection<File> changelogs = scanForChangelogs(changeLogSavePath);
339                generateUpdateLog(realChangeLogFile, changelogs);
340            }
341            catch (Exception e) {
342                throw new LiquibaseException(e);
343            }
344        }
345
346        getLog().info("Doing update");
347        liquibase.update(contexts);
348
349        getLog().info("Doing rollback");
350        liquibase.rollback(rollbackTag, contexts);
351    }
352
353    /**
354     * Parses a properties file and sets the assocaited fields in the plugin.
355     *
356     * @param propertiesInputStream The input stream which is the Liquibase properties that
357     *                              needs to be parsed.
358     * @throws org.apache.maven.plugin.MojoExecutionException
359     *          If there is a problem parsing
360     *          the file.
361     */
362    protected void parsePropertiesFile(InputStream propertiesInputStream)
363            throws MojoExecutionException {
364        if (propertiesInputStream == null) {
365            throw new MojoExecutionException("Properties file InputStream is null.");
366        }
367        Properties props = new Properties();
368        try {
369            props.load(propertiesInputStream);
370        }
371        catch (IOException e) {
372            throw new MojoExecutionException("Could not load the properties Liquibase file", e);
373        }
374
375        for (Iterator it = props.keySet().iterator(); it.hasNext();) {
376            String key = null;
377            try {
378                key = (String) it.next();
379                Field field = getDeclaredField(this.getClass(), key);
380
381                if (propertyFileWillOverride) {
382                    setFieldValue(field, props.get(key).toString());
383                } else {
384                    if (!isCurrentFieldValueSpecified(field)) {
385                        getLog().debug("  properties file setting value: " + field.getName());
386                        setFieldValue(field, props.get(key).toString());
387                    }
388                }
389            }
390            catch (Exception e) {
391                getLog().info("  '" + key + "' in properties file is not being used by this "
392                        + "task.");
393            }
394        }
395    }
396
397    /**
398     * This method will check to see if the user has specified a value different to that of
399     * the default value. This is not an ideal solution, but should cover most situations in
400     * the use of the plugin.
401     *
402     * @param f The Field to check if a user has specified a value for.
403     * @return <code>true</code> if the user has specified a value.
404     */
405    private boolean isCurrentFieldValueSpecified(Field f) throws IllegalAccessException {
406        Object currentValue = f.get(this);
407        if (currentValue == null) {
408            return false;
409        }
410
411        Object defaultValue = getDefaultValue(f);
412        if (defaultValue == null) {
413            return currentValue != null;
414        } else {
415            // There is a default value, check to see if the user has selected something other
416            // than the default
417            return !defaultValue.equals(f.get(this));
418        }
419    }
420
421    private Object getDefaultValue(Field field) throws IllegalAccessException {
422        List<Field> allFields = new ArrayList<Field>();
423        allFields.addAll(Arrays.asList(getClass().getDeclaredFields()));
424        allFields.addAll(Arrays.asList(AbstractLiquibaseMojo.class.getDeclaredFields()));
425
426        for (Field f : allFields) {
427            if (f.getName().equals(field.getName() + DEFAULT_FIELD_SUFFIX)) {
428                f.setAccessible(true);
429                return f.get(this);
430            }
431        }
432        return null;
433    }
434
435    
436    /**
437     * Recursively searches for the field specified by the fieldName in the class and all
438     * the super classes until it either finds it, or runs out of parents.
439     * @param clazz The Class to start searching from.
440     * @param fieldName The name of the field to retrieve.
441     * @return The {@link Field} identified by the field name.
442     * @throws NoSuchFieldException If the field was not found in the class or any of its
443     * super classes.
444     */
445    protected Field getDeclaredField(Class clazz, String fieldName)
446        throws NoSuchFieldException {
447        getLog().debug("Checking " + clazz.getName() + " for '" + fieldName + "'");
448        try {
449            Field f = clazz.getDeclaredField(fieldName);
450            
451            if (f != null) {
452                return f;
453            }
454        }
455        catch (Exception e) {
456        }
457        
458        while (clazz.getSuperclass() != null) {        
459            clazz = clazz.getSuperclass();
460            getLog().debug("Checking " + clazz.getName() + " for '" + fieldName + "'");
461            try {
462                Field f = clazz.getDeclaredField(fieldName);
463                
464                if (f != null) {
465                    return f;
466                }
467            }
468            catch (Exception e) {
469            }
470        }
471
472        throw new NoSuchFieldException("The field '" + fieldName + "' could not be "
473                                       + "found in the class of any of its parent "
474                                       + "classes.");
475    }
476
477    private void setFieldValue(Field field, String value) throws IllegalAccessException {
478        if (field.getType().equals(Boolean.class) || field.getType().equals(boolean.class)) {
479            field.set(this, Boolean.valueOf(value));
480        } else {
481            field.set(this, value);
482        }
483    }
484
485    protected Collection<File> scanForChangelogs(final File searchPath) {
486        final Collection<File> retval = new ArrayList<File>();
487        
488        if (searchPath.getName().endsWith("update")) {
489            return Arrays.asList(searchPath.listFiles());
490        }
491        
492        if (searchPath.isDirectory()) {
493            for (final File file : searchPath.listFiles()) {
494                if (file.isDirectory()) {
495                    retval.addAll(scanForChangelogs(file));
496                }
497            }
498        }
499        
500        return retval;
501    }
502
503    protected void generateUpdateLog(final File changeLogFile, final Collection<File> changelogs) throws FileNotFoundException, IOException {
504        changeLogFile.getParentFile().mkdirs();
505
506        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
507                DocumentBuilder documentBuilder;
508                try {
509                        documentBuilder = factory.newDocumentBuilder();
510                }
511                catch(ParserConfigurationException e) {
512                        throw new RuntimeException(e);
513                }
514                documentBuilder.setEntityResolver(new LiquibaseEntityResolver());
515
516                Document doc = documentBuilder.newDocument();
517                Element changeLogElement = doc.createElementNS(XMLChangeLogSAXParser.getDatabaseChangeLogNameSpace(), "databaseChangeLog");
518
519                changeLogElement.setAttribute("xmlns", XMLChangeLogSAXParser.getDatabaseChangeLogNameSpace());
520                changeLogElement.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
521                changeLogElement.setAttribute("xsi:schemaLocation", "http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-"+ XMLChangeLogSAXParser.getSchemaVersion()+ ".xsd");
522
523                doc.appendChild(changeLogElement);
524
525        for (final File changelog : changelogs) {
526            doc.getDocumentElement().appendChild(includeNode(doc, changelog));
527        }
528
529        new DefaultXmlWriter().write(doc, new FileOutputStream(changeLogFile));
530    }
531
532    protected Element includeNode(final Document parentChangeLog, final File changelog) throws IOException {
533        final Element retval = parentChangeLog.createElementNS(XMLChangeLogSAXParser.getDatabaseChangeLogNameSpace(), "include");
534        retval.setAttribute("file", changelog.getCanonicalPath());
535        return retval;
536    }
537}