001/* -*- mode: Java; c-basic-offset: 2; indent-tabs-mode: nil; coding: utf-8-unix -*- 002 * 003 * Copyright (c) 2014 Edugility LLC. 004 * 005 * Permission is hereby granted, free of charge, to any person 006 * obtaining a copy of this software and associated documentation 007 * files (the "Software"), to deal in the Software without 008 * restriction, including without limitation the rights to use, copy, 009 * modify, merge, publish, distribute, sublicense and/or sell copies 010 * of the Software, and to permit persons to whom the Software is 011 * furnished to do so, subject to the following conditions: 012 * 013 * The above copyright notice and this permission notice shall be 014 * included in all copies or substantial portions of the Software. 015 * 016 * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 017 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 018 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 019 * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 020 * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 021 * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 022 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 023 * DEALINGS IN THE SOFTWARE. 024 * 025 * The original copy of this license is available at 026 * http://www.opensource.org/license/mit-license.html. 027 */ 028package com.edugility.maven.liquibase; 029 030import java.io.BufferedReader; 031import java.io.BufferedWriter; 032import java.io.File; 033import java.io.FileOutputStream; 034import java.io.FileWriter; 035import java.io.InputStream; 036import java.io.InputStreamReader; 037import java.io.IOException; 038import java.io.OutputStreamWriter; 039 040import java.net.URL; 041 042import java.util.Collection; 043import java.util.HashMap; 044import java.util.Map; 045import java.util.Properties; 046 047import org.mvel2.templates.CompiledTemplate; 048import org.mvel2.templates.TemplateCompiler; 049import org.mvel2.templates.TemplateRuntime; 050 051/** 052 * A generator that creates a <a 053 * href="http://www.liquibase.org/">Liquibase</a> <a 054 * href="http://www.liquibase.org/documentation/databasechangelog.html">changelog 055 * file</a> with <a 056 * href="http://www.liquibase.org/documentation/include.html">{@code 057 * <include>} elements</a> inside it referencing other Liquibase 058 * changelog fragments. 059 * 060 * <p>This class is chiefly for use by a {@link 061 * LiquibaseChangeLogArtifactsProcessor}.</p> 062 * 063 * @author <a href="http://about.me/lairdnelson" 064 * target="_parent">Laird Nelson</a> 065 * 066 * @see LiquibaseChangeLogArtifactsProcessor 067 * 068 * @see <a 069 * href="http://ljnelson.github.io/artifact-maven-plugin/apidocs/index.html" 070 * target="_parent">The documentation for the 071 * artifact-maven-plugin</a> 072 * 073 * @see <a href="http://www.liquibase.org/">The Liquibase homepage</a> 074 * 075 * @see <a 076 * href="http://www.liquibase.org/documentation/databasechangelog.html">Documentation 077 * relating to the Liquibase changelog file</a> 078 */ 079public class AggregateChangeLogGenerator { 080 081 private static final String LS = System.getProperty("line.separator", "\n"); 082 083 /** 084 * The aggregating Liquibase changelog file that includes other 085 * changelog files in the appropriate order. 086 * 087 * <p>This field may be {@code null}.</p> 088 * 089 * @see #getAggregateChangeLogFile() 090 * 091 * @see #setAggregateChangeLogFile(File) 092 */ 093 private File aggregateChangeLogFile; 094 095 /** 096 * A {@link String} containing the contents of a logical template 097 * into which will be "poured" other logical changelog fragments. 098 * 099 * <p>This field may be {@code null}.</p> 100 * 101 * @see #getTemplate() 102 * 103 * @see #setTemplate(String) 104 */ 105 private String template; 106 107 /** 108 * The version of the Liquibase changelog file generated. 109 * 110 * <p>This field must not be {@code null}.</p> 111 * 112 * @see #getDatabaseChangeLogXsdVersion() 113 * 114 * @see #setDatabaseChangeLogXsdVersion(String) 115 */ 116 private String databaseChangeLogXsdVersion; 117 118 /** 119 * A {@link Properties} object representing Liquibase changelog 120 * parameters that will be included in the generated changelog. 121 * 122 * <p>This field may be {@code null}.</p> 123 * 124 * @see #getProperties() 125 * 126 * @see #setProperties(Properties) 127 */ 128 private Properties properties; 129 130 private String characterSet; 131 132 /** 133 * Represents whether the aggregate changelog was actually 134 * generated, or supplied via the {@link 135 * #setAggregateChangeLogFile(File)} method. 136 * 137 * @see #generateEmptyAggregateChangeLogFile() 138 */ 139 private transient boolean fileWasGenerated; 140 141 /** 142 * An MVEL representation of the contents of the {@link #template} 143 * field. 144 * 145 * <p>This field may be {@code null}.</p> 146 * 147 * @see #getTemplate() 148 * 149 * @see #setTemplate(String) 150 */ 151 private transient CompiledTemplate compiledTemplate; 152 153 154 /* 155 * Constructors. 156 */ 157 158 159 /** 160 * Creates a new {@link AggregateChangeLogGenerator}. 161 */ 162 public AggregateChangeLogGenerator() { 163 super(); 164 this.setDatabaseChangeLogXsdVersion("3.3"); 165 } 166 167 /** 168 * Returns the version of the Liquibase changelog file that will be 169 * generated. 170 * 171 * <p>This method may return {@code null} in which case "{@code 172 * 3.3}" will be used internally instead.</p> 173 * 174 * <p>By default, "{@code 3.3}" is returned, and this default value 175 * will change when Liquibase is updated and this project is updated 176 * to depend on it.</p> 177 * 178 * @return the version of the Liquibase changelog file that will be 179 * generated, or {@code null} 180 * 181 * @see #setDatabaseChangeLogXsdVersion(String) 182 */ 183 public String getDatabaseChangeLogXsdVersion() { 184 return this.databaseChangeLogXsdVersion; 185 } 186 187 /** 188 * Sets the version of the Liquibase changelog file that will be 189 * generated. 190 * 191 * @param version the new version; may be {@code null} 192 * 193 * @see #getDatabaseChangeLogXsdVersion() 194 */ 195 public void setDatabaseChangeLogXsdVersion(final String version) { 196 this.databaseChangeLogXsdVersion = version; 197 } 198 199 /** 200 * Loads a notional resource with the given {@code name} from the 201 * classpath. 202 * 203 * <p>The default implementation invokes the {@link 204 * ClassLoader#getResource(String)} method on the supplied {@link 205 * String} using, in order:</p> 206 * 207 * <ol> 208 * 209 * <li>the {@linkplain Thread#getContextClassLoader() context 210 * <code>ClassLoader</code>}</li> 211 * 212 * <li>this {@linkplain Class#getClassLoader() class' 213 * <code>ClassLoader</code>}</li> 214 * 215 * </ol> 216 * 217 * <p>The first of these {@link ClassLoader}s to return a non-{@code 218 * null} value will have its result returned. Otherwise, the return 219 * value of {@link ClassLoader#getSystemResource(String)} is 220 * returned.</p> 221 * 222 * @param name the name of the resource to load; may be {@code null} 223 * in which case {@code null} will be returned 224 * 225 * @return the resource that was found, or {@code null} 226 * 227 * @see ClassLoader#getResource(String) 228 */ 229 protected URL getResource(final String name) { 230 URL returnValue = null; 231 if (name != null) { 232 for (int i = 0; i < 3; i++) { 233 final ClassLoader loader; 234 switch (i) { 235 case 0: 236 loader = Thread.currentThread().getContextClassLoader(); 237 break; 238 case 1: 239 loader = this.getClass().getClassLoader(); 240 break; 241 case 2: 242 loader = null; 243 returnValue = ClassLoader.getSystemResource(name); 244 break; 245 default: 246 loader = null; 247 break; 248 } 249 if (loader != null) { 250 returnValue = loader.getResource(name); 251 } 252 if (returnValue != null) { 253 break; 254 } 255 } 256 } 257 return returnValue; 258 } 259 260 /** 261 * Returns a {@link Properties} object representing any custom 262 * properties that are to be converted to Liquibase changelog 263 * parameters. 264 * 265 * <p>This method may return {@code null}.</p> 266 * 267 * @return a {@link Properties} object representing custom 268 * properties, or {@code null} 269 * 270 * @see #setProperties(Properties) 271 */ 272 public Properties getProperties() { 273 return this.properties; 274 } 275 276 /** 277 * Installs a {@link Properties} object that represents custom 278 * properties that are to be converted to Liquibase changelog 279 * parameters. 280 * 281 * @param properties the {@link Properties} to be installed; may be 282 * {@code null} 283 * 284 * @see #getProperties() 285 */ 286 public void setProperties(final Properties properties) { 287 this.properties = properties; 288 } 289 290 public String getCharacterSet() { 291 if (this.characterSet == null) { 292 return "UTF-8"; 293 } else { 294 return this.characterSet; 295 } 296 } 297 298 public void setCharacterSet(final String characterSet) { 299 this.characterSet = characterSet; 300 } 301 302 /** 303 * Returns the source code of an <a 304 * href="http://mvel.codehaus.org/MVEL+2.0+Templating+Guide">MVEL 305 * template</a> that represents the skeleton of a Liquibase 306 * changelog into which will be placed {@code <include>} elements. 307 * 308 * <p>This method will not return {@code null} and overrides of it 309 * must not either.</p> 310 * 311 * @return a non-{@code null} {@link String} containing the source 312 * code of an <a 313 * href="http://mvel.codehaus.org/MVEL+2.0+Templating+Guide">MVEL 314 * template</a> 315 */ 316 public String getTemplate() { 317 if (this.template == null) { 318 final URL templateURL = this.getResource("changeLogTemplate.xml"); 319 if (templateURL != null) { 320 BufferedReader reader = null; 321 InputStream stream = null; 322 try { 323 stream = templateURL.openStream(); 324 if (stream != null) { 325 String characterSet = this.getCharacterSet(); 326 if (characterSet == null) { 327 characterSet = "UTF-8"; 328 } 329 reader = new BufferedReader(new InputStreamReader(stream, characterSet)); 330 String line = null; 331 final StringBuilder sb = new StringBuilder(); 332 while ((line = reader.readLine()) != null) { 333 sb.append(line); 334 sb.append(LS); 335 } 336 this.template = sb.toString(); 337 } 338 } catch (final IOException boom) { 339 // TODO: log 340 template = null; 341 } finally { 342 if (stream != null) { 343 try { 344 stream.close(); 345 } catch (final IOException nothingWeCanDo) { 346 347 } 348 } 349 if (reader != null) { 350 try { 351 reader.close(); 352 } catch (final IOException nothingWeCanDo) { 353 354 } 355 } 356 } 357 } 358 } 359 return this.template; 360 } 361 362 /** 363 * Sets the source code of an MVEL template that will be used to 364 * produce a Liquibase changelog. 365 * 366 * @param template the new source code; must not be {@code null} 367 * 368 * @exception IllegalArgumentException if {@code template} is {@code 369 * null} 370 * 371 * @see #getTemplate() 372 */ 373 public void setTemplate(final String template) { 374 if (template == null) { 375 throw new IllegalArgumentException("template", new NullPointerException("template")); 376 } 377 this.template = template; 378 this.compiledTemplate = null; 379 this.compiledTemplate = TemplateCompiler.compileTemplate(template); 380 } 381 382 /** 383 * Returns a {@link File} that either does house or will house 384 * Liquibase changelog contents. 385 * 386 * <p>This method may return {@code null}.</p> 387 * 388 * <p>This method may {@linkplain 389 * #generateEmptyAggregateChangeLogFile() generate} such a file.</p> 390 * 391 * @return a {@link File} representing the aggregate changelog, or 392 * {@code null} 393 * 394 * @see #generateEmptyAggregateChangeLogFile() 395 */ 396 public File getAggregateChangeLogFile() { 397 if (this.aggregateChangeLogFile == null) { 398 try { 399 this.aggregateChangeLogFile = this.generateEmptyAggregateChangeLogFile(); 400 } catch (final IOException oops) { 401 // TODO log 402 this.aggregateChangeLogFile = null; 403 } 404 } 405 return this.aggregateChangeLogFile; 406 } 407 408 /** 409 * Installs a {@link File} representing the path to a file that will 410 * be erased and that will eventually contain Liquibase changelog 411 * contents. 412 * 413 * @param file a path to a file that will be used as the Liquibase 414 * changelog file generated by this {@link 415 * AggregateChangeLogGenerator}; may be {@code null} 416 */ 417 public void setAggregateChangeLogFile(final File file) { 418 this.aggregateChangeLogFile = file; 419 this.fileWasGenerated = false; 420 } 421 422 /** 423 * Generates an empty (temporary) {@link File} that will eventually 424 * contain Liquibase changelog contents. 425 * 426 * <p>This implementation calls {@link File#createTempFile(String, 427 * String)} with {@code changelog} and {@code .tmp.xml} as its 428 * arguments, instructs the {@link File} so created to be 429 * {@linkplain File#deleteOnExit() deleted when the Java Virtual 430 * Machine exits} and returns the result.</p> 431 * 432 * @return a non-{@code null} {@link File} 433 * 434 * @exception IOException if an error occurs 435 */ 436 protected File generateEmptyAggregateChangeLogFile() throws IOException { 437 final File f = File.createTempFile("changelog", ".tmp.xml"); 438 assert f != null; 439 f.deleteOnExit(); 440 this.fileWasGenerated = true; 441 return f; 442 } 443 444 /** 445 * Generates a Liquibase changelog file that, from a high level, 446 * logically contains the Liquibase changelog fragments reachable 447 * from the supplied {@link URL}s. 448 * 449 * <p>This method never returns {@code null}.</p> 450 * 451 * @param resources a {@link Collection} of {@link URL}s, each 452 * element of which resolves to a Liquibase changelog file; may be 453 * {@code null} or {@linkplain Collection#isEmpty() empty} in whihc 454 * case a generally useless changelog will be generated 455 * 456 * @return a non-{@code null} {@link File} representing the path to 457 * the generated file 458 * 459 * @exception IOException if an error occurs 460 * 461 * @exception IllegalStateException if somehow the {@link File} into 462 * which content will be poured is {@code null} 463 * 464 * @see #getAggregateChangeLogFile() 465 * 466 * @see #generateEmptyAggregateChangeLogFile() 467 */ 468 public File generate(final Collection<? extends URL> resources) throws IOException { 469 // Get the aggregate file ready to go. 470 File aggregateChangeLogFile = this.getAggregateChangeLogFile(); 471 if (aggregateChangeLogFile == null) { 472 aggregateChangeLogFile = this.generateEmptyAggregateChangeLogFile(); 473 } 474 if (aggregateChangeLogFile == null) { 475 throw new IllegalStateException("Could not get or generate a temporary aggregate change log file"); 476 } 477 478 this.fill(aggregateChangeLogFile, resources); 479 480 return aggregateChangeLogFile; 481 } 482 483 private final void fill(final File changeLogFile, final Collection<? extends URL> resources) throws IOException { 484 if (changeLogFile == null) { 485 throw new IllegalArgumentException("changeLogFile", new NullPointerException("changeLogFile == null")); 486 } 487 final String changeLogContents = this.getAggregateChangeLogContents(resources); 488 if (changeLogContents == null) { 489 throw new IllegalStateException("this.getAggregateChangeLogContents() == null"); 490 } 491 this.write(changeLogFile, changeLogContents); 492 } 493 494 private final void write(final File aggregateChangeLogFile, final String changeLogContents) throws IOException { 495 if (aggregateChangeLogFile != null && changeLogContents != null) { 496 BufferedWriter writer = null; 497 try { 498 String characterSet = this.getCharacterSet(); 499 if (characterSet == null) { 500 characterSet = "UTF-8"; 501 } 502 writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(aggregateChangeLogFile), characterSet)); 503 writer.write(changeLogContents, 0, changeLogContents.length()); 504 writer.flush(); 505 } finally { 506 if (writer != null) { 507 try { 508 writer.close(); 509 } catch (final IOException nothingWeCanDo) { 510 511 } 512 } 513 } 514 } 515 } 516 517 private final String getAggregateChangeLogContents(final Collection<? extends URL> resources) throws IOException { 518 if (resources == null || resources.isEmpty()) { 519 throw new IllegalStateException("No sub-changelogs to aggregate"); 520 } 521 522 final Map<String, Object> parameters = new HashMap<String, Object>(5); 523 parameters.put("resources", resources); 524 parameters.put("databaseChangeLogXsdVersion", this.getDatabaseChangeLogXsdVersion()); 525 parameters.put("properties", this.getProperties()); 526 527 return this.getAggregateChangeLogContents(parameters); 528 } 529 530 private final String getAggregateChangeLogContents(final Map<?, ?> parameters) { 531 final String template = this.getTemplate(); 532 if (template == null) { 533 throw new IllegalStateException("No template present; please call setTemplate(String) first."); 534 } 535 536 String returnValue = null; 537 if (this.compiledTemplate == null) { 538 this.compiledTemplate = TemplateCompiler.compileTemplate(template); 539 assert this.compiledTemplate != null; 540 } 541 if (parameters == null || parameters.isEmpty()) { 542 returnValue = (String)TemplateRuntime.execute(compiledTemplate); 543 } else { 544 returnValue = (String)TemplateRuntime.execute(compiledTemplate, parameters); 545 } 546 return returnValue; 547 } 548 549}