001// Copyright 2014 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 Leo Przybylski ''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.serializer.LiquibaseSerializable;
045import liquibase.parser.NamespaceDetails;
046import liquibase.parser.NamespaceDetailsFactory;
047import liquibase.parser.core.xml.LiquibaseEntityResolver;
048import liquibase.parser.core.xml.XMLChangeLogSAXParser;
049import liquibase.serializer.core.xml.XMLChangeLogSerializer;
050import org.apache.maven.wagon.authentication.AuthenticationInfo;
051
052import liquibase.util.xml.DefaultXmlWriter;
053
054import org.w3c.dom.*;
055
056import javax.xml.parsers.DocumentBuilder;
057import javax.xml.parsers.DocumentBuilderFactory;
058import javax.xml.parsers.ParserConfigurationException;
059
060
061import java.lang.reflect.Field;
062import java.lang.reflect.Method;
063import java.io.File;
064import java.io.FilenameFilter;
065import java.io.FileNotFoundException;
066import java.io.FileOutputStream;
067import java.io.InputStream;
068import java.io.IOException;
069import java.net.URL;
070import java.util.ArrayList;
071import java.util.Arrays;
072import java.util.Collection;
073import java.util.HashMap;
074import java.util.Iterator;
075import java.util.List;
076import java.util.Map;
077import java.util.Properties;
078
079/**
080 * Tests Liquibase changelogs against various databases
081 *
082 * @author Leo Przybylski
083 * @goal test
084 */
085public class LiquibaseTestMojo extends AbstractLiquibaseChangeLogMojo {
086    public static final String DEFAULT_CHANGELOG_PATH = "src/main/changelogs";
087    public static final String DEFAULT_UPDATE_FILE    = "target/changelogs/update.xml";
088    public static final String DEFAULT_UPDATE_PATH    = "target/changelogs/update";
089    public static final String DEFAULT_LBPROP_PATH    = "target/test-classes/liquibase/";
090    public static final String TEST_ROLLBACK_TAG      = "test";
091
092    /**
093     * Suffix for fields that are representing a default value for a another field.
094     */
095    private static final String DEFAULT_FIELD_SUFFIX = "Default";
096
097    /**
098     * The fully qualified name of the driver class to use to connect to the database.
099     */
100    @Parameter(property = "liquibase.driver", required = true)
101    protected String driver;
102
103    /**
104     * The Database URL to connect to for executing Liquibase.
105     */
106    @Parameter(property = "liquibase.url", required = true)
107    protected String url;
108
109    /**
110     * 
111     * The Maven Wagon manager to use when obtaining server authentication details.
112     */
113    @Component(role=org.apache.maven.artifact.manager.WagonManager.class)
114    protected WagonManager wagonManager;
115
116    /**
117     * The server id in settings.xml to use when authenticating with.
118     */
119    @Parameter(property = "liquibase.server", required = true)
120    private String server;
121
122    /**
123     * The database username to use to connect to the specified database.
124     */
125    @Parameter(property = "liquibase.username", required = true)
126    protected String username;
127
128    /**
129     * The database password to use to connect to the specified database.
130     */
131    @Parameter(property = "liquibase.password", required = true)
132    protected String password;
133    
134    /**
135     * The default schema name to use the for database connection.
136     */
137    @Parameter(property = "liquibase.defaultSchemaName")
138    protected String defaultSchemaName;
139
140    /**
141     * The class to use as the database object.
142     */
143    @Parameter(property = "liquibase.databaseClass", required = true)
144    protected String databaseClass;
145
146    /**
147     * Controls the prompting of users as to whether or not they really want to run the
148     * changes on a database that is not local to the machine that the user is current
149     * executing the plugin on.
150     *
151     * @parameter expression="${liquibase.promptOnNonLocalDatabase}" default-value="false"
152     */
153    protected boolean promptOnNonLocalDatabase;
154
155    /**
156     * Allows for the maven project artifact to be included in the class loader for
157     * obtaining the Liquibase property and DatabaseChangeLog files.
158     *
159     * @parameter expression="${liquibase.includeArtifact}" default-value="true"
160     */
161    protected boolean includeArtifact;
162
163    /**
164     * Allows for the maven test output directory to be included in the class loader for
165     * obtaining the Liquibase property and DatabaseChangeLog files.
166     *
167     * @parameter expression="${liquibase.includeTestOutputDirectory}" default-value="true"
168     */
169    protected boolean includeTestOutputDirectory;
170
171    /**
172     * Controls the verbosity of the output from invoking the plugin.
173     *
174     * @parameter expression="${liquibase.verbose}" default-value="false"
175     * @description Controls the verbosity of the plugin when executing
176     */
177    protected boolean verbose;
178
179    /**
180     * Controls the level of logging from Liquibase when executing. The value can be
181     * "all", "finest", "finer", "fine", "info", "warning", "severe" or "off". The value is
182     * case insensitive.
183     *
184     * @parameter expression="${liquibase.logging}" default-value="INFO"
185     * @description Controls the verbosity of the plugin when executing
186     */
187    protected String logging;
188
189    /**
190     * The Liquibase properties file used to configure the Liquibase {@link
191     * liquibase.Liquibase}.
192     *
193     * @parameter expression="${liquibase.propertyFile}"
194     */
195    protected String propertyFile;
196
197    /**
198     * Flag allowing for the Liquibase properties file to override any settings provided in
199     * the Maven plugin configuration. By default if a property is explicity specified it is
200     * not overridden if it also appears in the properties file.
201     *
202     * @parameter expression="${liquibase.propertyFileWillOverride}" default-value="true"
203     */
204    protected boolean propertyFileWillOverride;
205
206    /**
207     * Flag for forcing the checksums to be cleared from teh DatabaseChangeLog table.
208     *
209     * @parameter expression="${liquibase.clearCheckSums}" default-value="false"
210     */
211    protected boolean clearCheckSums;
212
213    /**                                                                                                                                                                          
214     * List of system properties to pass to the database.                                                                                                                        
215     *                                                                                                                                                                           
216     * @parameter                                                                                                                                                                
217     */                                                                                                                                                                          
218    protected Properties systemProperties;
219
220    /**
221     * Specifies the change log file to use for Liquibase. No longer needed with updatePath.
222     * @parameter expression="${liquibase.changeLogFile}"
223     * @deprecated
224     */
225    protected String changeLogFile;
226
227    /**
228     * @parameter default-value="${project.basedir}/src/main/scripts/changelogs"
229     */
230    protected File changeLogSavePath;
231
232    /**
233     * Location of an update.xml
234     */
235    @Parameter(property = "lb.updatePath", defaultValue="${project.basedir}/src/main/scripts/changelogs")
236    protected File updatePath;
237    
238    /**
239     * The tag to roll the database back to. 
240     */
241    @Parameter(property = "liquibase.rollbackTag")
242    protected String rollbackTag;
243    
244    /**
245     * The Maven project that plugin is running under.
246     * @parameter expression="${project}"
247     * @required
248     * @readonly
249     */
250    @Parameter(property = "project", required = true)
251    protected MavenProject project;
252
253
254    /**
255     * The Liquibase contexts to execute, which can be "," separated if multiple contexts
256     * are required. If no context is specified then ALL contexts will be executed.
257     * @parameter expression="${liquibase.contexts}" default-value=""
258     */
259    protected String contexts;
260
261    protected File getBasedir() {
262        return project.getBasedir();
263    }
264
265    protected void doFieldHack() {
266        for (final Field field : getClass().getDeclaredFields()) {
267            try {
268                final Field parentField = getDeclaredField(getClass().getSuperclass(), field.getName());
269                if (parentField != null) {
270                    getLog().debug("Setting " + field.getName() + " in " + parentField.getDeclaringClass().getName() + " to " + field.get(this));
271                    parentField.set(this, field.get(this));
272                }
273            }
274            catch (Exception e) {
275            }
276        }
277    }
278
279    protected File[] getLiquibasePropertiesFiles() throws MojoExecutionException {
280        try {
281            final File[] retval = new File(getBasedir(), DEFAULT_LBPROP_PATH).listFiles(new FilenameFilter() {
282                    public boolean accept(final File dir, final String name) {
283                        return name.endsWith(".properties");
284                    }
285                });
286            if (retval == null) {
287                throw new NullPointerException();
288            }            
289            return retval;
290        }
291        catch (Exception e) {
292            getLog().warn("Unable to get liquibase properties files ");
293            return new File[0];
294            // throw new MojoExecutionException("Unable to get liquibase properties files ", e);
295        }
296    }
297
298    @Override
299    public void execute() throws MojoExecutionException, MojoFailureException {
300        changeLogFile = DEFAULT_UPDATE_FILE;
301        try {
302            Method meth = AbstractLiquibaseMojo.class.getDeclaredMethod("processSystemProperties");
303            meth.setAccessible(true);
304            meth.invoke(this);
305        }
306        catch (Exception e) {
307            e.printStackTrace();
308        }
309        super.project = this.project;
310
311        ClassLoader artifactClassLoader = getMavenArtifactClassLoader();
312        final File[] propertyFiles = getLiquibasePropertiesFiles();
313        
314        // execute change logs on each database
315        for (final File props : propertyFiles) {
316            try {
317                propertyFile = props.getCanonicalPath();
318                doFieldHack();
319                
320                configureFieldsAndValues(getFileOpener(artifactClassLoader));
321                
322                doFieldHack();
323            }
324            catch (Exception e) {
325                throw new MojoExecutionException(e.getMessage(), e);
326            }
327            
328            super.execute();
329        }
330    }
331
332    @Override
333    protected void performLiquibaseTask(Liquibase liquibase) throws LiquibaseException {
334        super.performLiquibaseTask(liquibase);
335        
336        getLog().info("Tagging the database");
337        rollbackTag = TEST_ROLLBACK_TAG;
338        liquibase.tag(rollbackTag);
339
340        final File realChangeLogFile = new File(changeLogFile);
341        // If there isn't an update.xml, then make one       
342        if (!realChangeLogFile.exists()) {
343            try {
344                final Collection<File> changelogs = scanForChangelogs(changeLogSavePath);
345                generateUpdateLog(realChangeLogFile, changelogs);
346            }
347            catch (Exception e) {
348                throw new LiquibaseException(e);
349            }
350        }
351
352        getLog().info("Doing update");
353        liquibase.update(contexts);
354
355        getLog().info("Doing rollback");
356        liquibase.rollback(rollbackTag, contexts);
357    }
358
359    /**
360     * Parses a properties file and sets the assocaited fields in the plugin.
361     *
362     * @param propertiesInputStream The input stream which is the Liquibase properties that
363     *                              needs to be parsed.
364     * @throws org.apache.maven.plugin.MojoExecutionException
365     *          If there is a problem parsing
366     *          the file.
367     */
368    protected void parsePropertiesFile(InputStream propertiesInputStream)
369        throws MojoExecutionException {
370        if (propertiesInputStream == null) {
371            throw new MojoExecutionException("Properties file InputStream is null.");
372        }
373        Properties props = new Properties();
374        try {
375            props.load(propertiesInputStream);
376        }
377        catch (IOException e) {
378            throw new MojoExecutionException("Could not load the properties Liquibase file", e);
379        }
380
381        for (Iterator it = props.keySet().iterator(); it.hasNext();) {
382            String key = null;
383            try {
384                key = (String) it.next();
385                Field field = getDeclaredField(this.getClass(), key);
386
387                if (propertyFileWillOverride) {
388                    setFieldValue(field, props.get(key).toString());
389                } else {
390                    if (!isCurrentFieldValueSpecified(field)) {
391                        getLog().debug("  properties file setting value: " + field.getName());
392                        setFieldValue(field, props.get(key).toString());
393                    }
394                }
395            }
396            catch (Exception e) {
397                getLog().info("  '" + key + "' in properties file is not being used by this "
398                              + "task.");
399            }
400        }
401    }
402
403    /**
404     * This method will check to see if the user has specified a value different to that of
405     * the default value. This is not an ideal solution, but should cover most situations in
406     * the use of the plugin.
407     *
408     * @param f The Field to check if a user has specified a value for.
409     * @return <code>true</code> if the user has specified a value.
410     */
411    private boolean isCurrentFieldValueSpecified(Field f) throws IllegalAccessException {
412        Object currentValue = f.get(this);
413        if (currentValue == null) {
414            return false;
415        }
416
417        Object defaultValue = getDefaultValue(f);
418        if (defaultValue == null) {
419            return currentValue != null;
420        } else {
421            // There is a default value, check to see if the user has selected something other
422            // than the default
423            return !defaultValue.equals(f.get(this));
424        }
425    }
426
427    private Object getDefaultValue(Field field) throws IllegalAccessException {
428        List<Field> allFields = new ArrayList<Field>();
429        allFields.addAll(Arrays.asList(getClass().getDeclaredFields()));
430        allFields.addAll(Arrays.asList(AbstractLiquibaseMojo.class.getDeclaredFields()));
431
432        for (Field f : allFields) {
433            if (f.getName().equals(field.getName() + DEFAULT_FIELD_SUFFIX)) {
434                f.setAccessible(true);
435                return f.get(this);
436            }
437        }
438        return null;
439    }
440
441    
442    /**
443     * Recursively searches for the field specified by the fieldName in the class and all
444     * the super classes until it either finds it, or runs out of parents.
445     * @param clazz The Class to start searching from.
446     * @param fieldName The name of the field to retrieve.
447     * @return The {@link Field} identified by the field name.
448     * @throws NoSuchFieldException If the field was not found in the class or any of its
449     * super classes.
450     */
451    protected Field getDeclaredField(Class clazz, String fieldName)
452        throws NoSuchFieldException {
453        getLog().debug("Checking " + clazz.getName() + " for '" + fieldName + "'");
454        try {
455            Field f = clazz.getDeclaredField(fieldName);
456            
457            if (f != null) {
458                return f;
459            }
460        }
461        catch (Exception e) {
462        }
463        
464        while (clazz.getSuperclass() != null) {        
465            clazz = clazz.getSuperclass();
466            getLog().debug("Checking " + clazz.getName() + " for '" + fieldName + "'");
467            try {
468                Field f = clazz.getDeclaredField(fieldName);
469                
470                if (f != null) {
471                    return f;
472                }
473            }
474            catch (Exception e) {
475            }
476        }
477
478        throw new NoSuchFieldException("The field '" + fieldName + "' could not be "
479                                       + "found in the class of any of its parent "
480                                       + "classes.");
481    }
482
483    private void setFieldValue(Field field, String value) throws IllegalAccessException {
484        if (field.getType().equals(Boolean.class) || field.getType().equals(boolean.class)) {
485            field.set(this, Boolean.valueOf(value));
486        } else {
487            field.set(this, value);
488        }
489    }
490
491    protected Collection<File> scanForChangelogs(final File searchPath) {
492        final Collection<File> retval = new ArrayList<File>();
493        
494        if (searchPath.getName().endsWith("update")) {
495            return Arrays.asList(searchPath.listFiles());
496        }
497        
498        if (searchPath.isDirectory()) {
499            for (final File file : searchPath.listFiles()) {
500                if (file.isDirectory()) {
501                    retval.addAll(scanForChangelogs(file));
502                }
503            }
504        }
505        
506        return retval;
507    }
508
509    protected void generateUpdateLog(final File changeLogFile, final Collection<File> changelogs) throws FileNotFoundException, IOException {
510        changeLogFile.getParentFile().mkdirs();
511
512        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
513        DocumentBuilder documentBuilder;
514        try {
515            documentBuilder = factory.newDocumentBuilder();
516        }
517        catch(ParserConfigurationException e) {
518            throw new RuntimeException(e);
519        }
520        final XMLChangeLogSerializer serializer = new XMLChangeLogSerializer();
521        documentBuilder.setEntityResolver(new LiquibaseEntityResolver(serializer));
522
523        Document doc = documentBuilder.newDocument();
524        final Element changeLogElement = doc.createElementNS(LiquibaseSerializable.STANDARD_CHANGELOG_NAMESPACE, "databaseChangeLog");
525
526        changeLogElement.setAttribute("xmlns", LiquibaseSerializable.STANDARD_CHANGELOG_NAMESPACE);
527        changeLogElement.setAttribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
528
529        final Map<String, String> shortNameByNamespace = new HashMap<String, String>();
530        final Map<String, String> urlByNamespace = new HashMap<String, String>();
531
532        for (final NamespaceDetails details : NamespaceDetailsFactory.getInstance().getNamespaceDetails()) {
533            for (final String namespace : details.getNamespaces()) {
534                if (details.supports(serializer, namespace)){
535                    final String shortName = details.getShortName(namespace);
536                    final String url = details.getSchemaUrl(namespace);
537                    if (shortName != null && url != null) {
538                        shortNameByNamespace.put(namespace, shortName);
539                        urlByNamespace.put(namespace, url);
540                    }
541                }
542            }
543        }
544
545        for (final Map.Entry<String, String> entry : shortNameByNamespace.entrySet()) {
546            if (!entry.getValue().equals("")) {
547                changeLogElement.setAttribute("xmlns:"+entry.getValue(), entry.getKey());
548            }
549        }
550
551
552        String schemaLocationAttribute = "";
553        for (final Map.Entry<String, String> entry : urlByNamespace.entrySet()) {
554            if (!entry.getValue().equals("")) {
555                schemaLocationAttribute += entry.getKey()+" "+entry.getValue()+" ";
556            }
557        }
558
559        changeLogElement.setAttribute("xsi:schemaLocation", schemaLocationAttribute.trim());
560
561        doc.appendChild(changeLogElement);
562
563        for (final File changelog : changelogs) {
564            doc.getDocumentElement().appendChild(includeNode(doc, changelog));
565        }
566
567        new DefaultXmlWriter().write(doc, new FileOutputStream(changeLogFile));
568    }
569
570    protected Element includeNode(final Document parentChangeLog, final File changelog) throws IOException {
571        final Element retval = parentChangeLog.createElementNS(LiquibaseSerializable.STANDARD_CHANGELOG_NAMESPACE, 
572                                                               "databaseChangeLog");
573        retval.setAttribute("file", changelog.getCanonicalPath());
574        return retval;
575    }
576}