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}