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}