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}