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}