3b3a72b79137b1d7a46b4d19775e3f4c40ff838f
[hibernate4-maven-plugin] / src / main / java / de / juplo / plugins / hibernate4 / Hbm2DdlMojo.java
1 package de.juplo.plugins.hibernate4;
2
3 /*
4  * Copyright 2001-2005 The Apache Software Foundation.
5  *
6  * Licensed under the Apache License, Version 2.0 (the "License");
7  * you may not use this file except in compliance with the License.
8  * You may obtain a copy of the License at
9  *
10  *      http://www.apache.org/licenses/LICENSE-2.0
11  *
12  * Unless required by applicable law or agreed to in writing, software
13  * distributed under the License is distributed on an "AS IS" BASIS,
14  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15  * See the License for the specific language governing permissions and
16  * limitations under the License.
17  */
18
19 import com.pyx4j.log4j.MavenLogAppender;
20 import java.io.File;
21 import java.io.FileInputStream;
22 import java.io.FileOutputStream;
23 import java.io.IOException;
24 import java.io.InputStream;
25 import java.io.ObjectInputStream;
26 import java.io.ObjectOutputStream;
27 import java.math.BigInteger;
28 import java.net.URL;
29 import java.net.URLClassLoader;
30 import java.security.MessageDigest;
31 import java.sql.Connection;
32 import java.sql.Driver;
33 import java.sql.DriverManager;
34 import java.sql.DriverPropertyInfo;
35 import java.sql.SQLException;
36 import java.sql.SQLFeatureNotSupportedException;
37 import java.util.Comparator;
38 import java.util.Enumeration;
39 import java.util.HashMap;
40 import java.util.HashSet;
41 import java.util.List;
42 import java.util.Map;
43 import java.util.Map.Entry;
44 import java.util.Properties;
45 import java.util.Set;
46 import java.util.TreeSet;
47 import java.util.logging.Logger;
48 import javax.persistence.Embeddable;
49 import javax.persistence.Entity;
50 import javax.persistence.MappedSuperclass;
51 import org.apache.maven.plugin.AbstractMojo;
52 import org.apache.maven.plugin.MojoExecutionException;
53 import org.apache.maven.plugin.MojoFailureException;
54 import org.apache.maven.project.MavenProject;
55 import org.hibernate.cfg.Configuration;
56 import org.hibernate.tool.hbm2ddl.SchemaExport;
57 import org.hibernate.tool.hbm2ddl.SchemaExport.Type;
58 import org.hibernate.tool.hbm2ddl.Target;
59 import org.scannotation.AnnotationDB;
60
61
62 /**
63  * Goal which extracts the hibernate-mapping-configuration and
64  * exports an according SQL-database-schema.
65  *
66  * @goal export
67  * @phase process-classes
68  * @threadSafe
69  * @requiresDependencyResolution runtime
70  */
71 public class Hbm2DdlMojo extends AbstractMojo
72 {
73   public final static String EXPORT_SKIPPED_PROPERTY = "hibernate.export.skipped";
74
75   public final static String DRIVER_CLASS = "hibernate.connection.driver_class";
76   public final static String URL = "hibernate.connection.url";
77   public final static String USERNAME = "hibernate.connection.username";
78   public final static String PASSWORD = "hibernate.connection.password";
79   public final static String DIALECT = "hibernate.dialect";
80
81   private final static String MD5S = "schema.md5s";
82
83   /**
84    * The maven project.
85    * <p>
86    * Only needed internally.
87    *
88    * @parameter expression="${project}"
89    * @required
90    * @readonly
91    */
92   private MavenProject project;
93
94   /**
95    * Build-directory.
96    * <p>
97    * Only needed internally.
98    *
99    * @parameter expression="${project.build.directory}"
100    * @required
101    * @readonly
102    */
103   private String buildDirectory;
104
105   /**
106    * Classes-Directory to scan.
107    * <p>
108    * This parameter defaults to the maven build-output-directory for classes.
109    * Additonally, all dependencies are scanned for annotated classes.
110    *
111    * @parameter expression="${project.build.outputDirectory}"
112    */
113   private String outputDirectory;
114
115   /**
116    * Wether to scan test-classes too, or not.
117    * <p>
118    * If this parameter is set to <code>true</code> the test-classes of the
119    * artifact will be scanned for hibernate-annotated classes additionally.
120    *
121    * @parameter expression="${hibernate.export.scann_testclasses}" default-value="false"
122    */
123   private boolean scanTestClasses;
124
125   /**
126    * Test-Classes-Directory to scan.
127    * <p>
128    * This parameter defaults to the maven build-output-directory for
129    * test-classes.
130    * <p>
131    * This parameter is only used, when <code>scanTestClasses</code> is set
132    * to <code>true</code>!
133    *
134    * @parameter expression="${project.build.testOutputDirectory}"
135    */
136   private String testOutputDirectory;
137
138   /**
139    * Skip execution
140    * <p>
141    * If set to <code>true</code>, the execution is skipped.
142    * <p>
143    * A skipped excecution is signaled via the maven-property
144    * <code>${hibernate.export.skipped}</code>.
145    * <p>
146    * The excecution is skipped automatically, if no modified or newly added
147    * annotated classes are found and the dialect was not changed.
148    *
149    * @parameter expression="${maven.test.skip}" default-value="false"
150    */
151   private boolean skip;
152
153   /**
154    * Force execution
155    * <p>
156    * Force execution, even if no modified or newly added annotated classes
157    * where found and the dialect was not changed.
158    * <p>
159    * <code>skip</code> takes precedence over <code>force</code>.
160    *
161    * @parameter expression="${hibernate.export.force}" default-value="false"
162    */
163   private boolean force;
164
165   /**
166    * SQL-Driver name.
167    *
168    * @parameter expression="${hibernate.connection.driver_class}
169    */
170   private String driverClassName;
171
172   /**
173    * Database URL.
174    *
175    * @parameter expression="${hibernate.connection.url}"
176    */
177   private String url;
178
179   /**
180    * Database username
181    *
182    * @parameter expression="${hibernate.connection.username}"
183    */
184   private String username;
185
186   /**
187    * Database password
188    *
189    * @parameter expression="${hibernate.connection.password}"
190    */
191   private String password;
192
193   /**
194    * Hibernate dialect.
195    *
196    * @parameter expression="${hibernate.dialect}"
197    */
198   private String hibernateDialect;
199
200   /**
201    * Path to Hibernate configuration file.
202    *
203    * @parameter default-value="${project.build.outputDirectory}/hibernate.properties"
204    */
205   private String hibernateProperties;
206
207   /**
208    * Target of execution:
209    * <ul>
210    *   <li><strong>NONE</strong> do nothing - just validate the configuration (forces excecution, signals skip)</li>
211    *   <li><strong>EXPORT</strong> create database (<strong>DEFAULT!</strong>. forces excecution, signals skip)</li>
212    *   <li><strong>SCRIPT</strong> export schema to SQL-script</li>
213    *   <li><strong>BOTH</strong></li>
214    * </ul>
215    * @parameter expression="${hibernate.export.target}" default-value="EXPORT"
216    */
217   private String target;
218
219   /**
220    * Type of execution.
221    * <ul>
222    *   <li><strong>NONE</strong> do nothing - just validate the configuration</li>
223    *   <li><strong>CREATE</strong> create database-schema</li>
224    *   <li><strong>DROP</strong> drop database-schema</li>
225    *   <li><strong>BOTH</strong> (<strong>DEFAULT!</strong>)</li>
226    * </ul>
227    * @parameter expression="${hibernate.export.type}" default-value="BOTH"
228    */
229   private String type;
230
231   /**
232    * Output file.
233    *
234    * @parameter expression="${hibernate.export.schema.filename}" default-value="${project.build.directory}/schema.sql"
235    */
236   private String outputFile;
237
238   /**
239    * Delimiter in output-file.
240    *
241    * @parameter expression="${hibernate.export.schema.delimiter}" default-value=";"
242    */
243   private String delimiter;
244
245   /**
246    * Format output-file.
247    *
248    * @parameter expression="${hibernate.export.schema.format}" default-value="true"
249    */
250   private boolean format;
251
252
253   @Override
254   public void execute()
255     throws
256       MojoFailureException,
257       MojoExecutionException
258   {
259     if (skip)
260     {
261       getLog().info("Exectuion of hibernate4-maven-plugin:export was skipped!");
262       project.getProperties().setProperty(EXPORT_SKIPPED_PROPERTY, "true");
263       return;
264     }
265
266     File dir = new File(outputDirectory);
267     if (!dir.exists())
268       throw new MojoExecutionException("Cannot scan for annotated classes in " + outputDirectory + ": directory does not exist!");
269
270     Map<String,String> md5s;
271     boolean modified = false;
272     File saved = new File(buildDirectory + File.separator + MD5S);
273
274     if (saved.exists())
275     {
276       try
277       {
278         FileInputStream fis = new FileInputStream(saved);
279         ObjectInputStream ois = new ObjectInputStream(fis);
280         md5s = (HashMap<String,String>)ois.readObject();
281         ois.close();
282       }
283       catch (Exception e)
284       {
285         md5s = new HashMap<String,String>();
286         getLog().warn("Cannot read timestamps from saved: " + e);
287       }
288     }
289     else
290     {
291       md5s = new HashMap<String,String>();
292       try
293       {
294         saved.createNewFile();
295       }
296       catch (IOException e)
297       {
298         getLog().warn("Cannot create saved for timestamps: " + e);
299       }
300     }
301
302     ClassLoader classLoader = null;
303     try
304     {
305       getLog().debug("Creating ClassLoader for project-dependencies...");
306       List<String> classpathFiles = project.getCompileClasspathElements();
307       if (scanTestClasses)
308         classpathFiles.addAll(project.getTestClasspathElements());
309       URL[] urls = new URL[classpathFiles.size()];
310       for (int i = 0; i < classpathFiles.size(); ++i)
311       {
312         getLog().debug("Dependency: " + classpathFiles.get(i));
313         urls[i] = new File(classpathFiles.get(i)).toURI().toURL();
314       }
315       classLoader = new URLClassLoader(urls, getClass().getClassLoader());
316     }
317     catch (Exception e)
318     {
319       getLog().error("Error while creating ClassLoader!", e);
320       throw new MojoExecutionException(e.getMessage());
321     }
322
323     Set<Class<?>> classes =
324         new TreeSet<Class<?>>(
325             new Comparator<Class<?>>() {
326               @Override
327               public int compare(Class<?> a, Class<?> b)
328               {
329                 return a.getName().compareTo(b.getName());
330               }
331             }
332           );
333
334     try
335     {
336       AnnotationDB db = new AnnotationDB();
337       getLog().info("Scanning directory " + outputDirectory + " for annotated classes...");
338       URL dirUrl = dir.toURI().toURL();
339       db.scanArchives(dirUrl);
340       if (scanTestClasses)
341       {
342         dir = new File(testOutputDirectory);
343         if (!dir.exists())
344           throw new MojoExecutionException("Cannot scan for annotated test-classes in " + testOutputDirectory + ": directory does not exist!");
345         getLog().info("Scanning directory " + testOutputDirectory + " for annotated classes...");
346         dirUrl = dir.toURI().toURL();
347         db.scanArchives(dirUrl);
348       }
349
350       Set<String> classNames = new HashSet<String>();
351       if (db.getAnnotationIndex().containsKey(Entity.class.getName()))
352         classNames.addAll(db.getAnnotationIndex().get(Entity.class.getName()));
353       if (db.getAnnotationIndex().containsKey(MappedSuperclass.class.getName()))
354         classNames.addAll(db.getAnnotationIndex().get(MappedSuperclass.class.getName()));
355       if (db.getAnnotationIndex().containsKey(Embeddable.class.getName()))
356         classNames.addAll(db.getAnnotationIndex().get(Embeddable.class.getName()));
357
358       MessageDigest digest = java.security.MessageDigest.getInstance("MD5");
359       for (String name : classNames)
360       {
361         Class<?> annotatedClass = classLoader.loadClass(name);
362         classes.add(annotatedClass);
363         InputStream is =
364             annotatedClass
365                 .getResourceAsStream(annotatedClass.getSimpleName() + ".class");
366         byte[] buffer = new byte[1024*4]; // copy data in 4MB-chunks
367         int i;
368         while((i = is.read(buffer)) > -1)
369           digest.update(buffer, 0, i);
370         is.close();
371         byte[] bytes = digest.digest();
372         BigInteger bi = new BigInteger(1, bytes);
373         String newMd5 = String.format("%0" + (bytes.length << 1) + "x", bi);
374         String oldMd5 = !md5s.containsKey(name) ? "" : md5s.get(name);
375         if (!newMd5.equals(oldMd5))
376         {
377           getLog().debug("Found new or modified annotated class: " + name);
378           modified = true;
379           md5s.put(name, newMd5);
380         }
381         else
382         {
383           getLog().debug(oldMd5 + " -> class unchanged: " + name);
384         }
385       }
386     }
387     catch (ClassNotFoundException e)
388     {
389       getLog().error("Error while adding annotated classes!", e);
390       throw new MojoExecutionException(e.getMessage());
391     }
392     catch (Exception e)
393     {
394       getLog().error("Error while scanning!", e);
395       throw new MojoFailureException(e.getMessage());
396     }
397
398     if (classes.isEmpty())
399       throw new MojoFailureException("No annotated classes found in directory " + outputDirectory);
400
401     getLog().debug("Detected classes with mapping-annotations:");
402     for (Class<?> annotatedClass : classes)
403       getLog().debug("  " + annotatedClass.getName());
404
405
406     Properties properties = new Properties();
407
408     /** Try to read configuration from properties-file */
409     try
410     {
411       File file = new File(hibernateProperties);
412       if (file.exists())
413       {
414         getLog().info("Reading properties from file " + hibernateProperties + "...");
415         properties.load(new FileInputStream(file));
416       }
417       else
418         getLog().info("No hibernate-properties-file found! (Checked path: " + hibernateProperties + ")");
419     }
420     catch (IOException e)
421     {
422       getLog().error("Error while reading properties!", e);
423       throw new MojoExecutionException(e.getMessage());
424     }
425
426     /** Overwrite values from propertie-file or set, if given */
427     if (driverClassName != null)
428     {
429       if (properties.containsKey(DRIVER_CLASS))
430         getLog().debug(
431             "Overwriting property " +
432             DRIVER_CLASS + "=" + properties.getProperty(DRIVER_CLASS) +
433             " with the value " + driverClassName
434           );
435       else
436         getLog().debug("Using the value " + driverClassName);
437       properties.setProperty(DRIVER_CLASS, driverClassName);
438     }
439     if (url != null)
440     {
441       if (properties.containsKey(URL))
442         getLog().debug(
443             "Overwriting property " +
444             URL + "=" + properties.getProperty(URL) +
445             " with the value " + url
446           );
447       else
448         getLog().debug("Using the value " + url);
449       properties.setProperty(URL, url);
450     }
451     if (username != null)
452     {
453       if (properties.containsKey(USERNAME))
454         getLog().debug(
455             "Overwriting property " +
456             USERNAME + "=" + properties.getProperty(USERNAME) +
457             " with the value " + username
458           );
459       else
460         getLog().debug("Using the value " + username);
461       properties.setProperty(USERNAME, username);
462     }
463     if (password != null)
464     {
465       if (properties.containsKey(PASSWORD))
466         getLog().debug(
467             "Overwriting property " +
468             PASSWORD + "=" + properties.getProperty(PASSWORD) +
469             " with the value " + password 
470           );
471       else
472         getLog().debug("Using the value " + password);
473       properties.setProperty(PASSWORD, password);
474     }
475     if (hibernateDialect != null)
476     {
477       if (properties.containsKey(DIALECT))
478         getLog().debug(
479             "Overwriting property " +
480             DIALECT + "=" + properties.getProperty(DIALECT) +
481             " with the value " + hibernateDialect
482           );
483       else
484         getLog().debug("Using the value " + hibernateDialect);
485       properties.setProperty(DIALECT, hibernateDialect);
486     }
487
488     /** The generated SQL varies with the dialect! */
489     if (md5s.containsKey(DIALECT))
490     {
491       String dialect = properties.getProperty(DIALECT);
492       if (md5s.get(DIALECT).equals(dialect))
493         getLog().debug("SQL-dialect unchanged.");
494       else
495       {
496         getLog().debug("SQL-dialect changed: " + dialect);
497         modified = true;
498         md5s.put(DIALECT, dialect);
499       }
500     }
501     else
502     {
503       modified = true;
504       md5s.put(DIALECT, properties.getProperty(DIALECT));
505     }
506
507     if (properties.isEmpty())
508     {
509       getLog().error("No properties set!");
510       throw new MojoFailureException("Hibernate-Configuration is missing!");
511     }
512
513     Configuration config = new Configuration();
514     config.setProperties(properties);
515     getLog().debug("Adding annotated classes to hibernate-mapping-configuration...");
516     for (Class<?> annotatedClass : classes)
517     {
518       getLog().debug("Class " + annotatedClass);
519       config.addAnnotatedClass(annotatedClass);
520     }
521
522     Target target = null;
523     try
524     {
525       target = Target.valueOf(this.target.toUpperCase());
526     }
527     catch (IllegalArgumentException e)
528     {
529       getLog().error("Invalid value for configuration-option \"target\": " + this.target);
530       getLog().error("Valid values are: NONE, SCRIPT, EXPORT, BOTH");
531       throw new MojoExecutionException("Invalid value for configuration-option \"target\"");
532     }
533     Type type = null;
534     try
535     {
536       type = Type.valueOf(this.type.toUpperCase());
537     }
538     catch (IllegalArgumentException e)
539     {
540       getLog().error("Invalid value for configuration-option \"type\": " + this.type);
541       getLog().error("Valid values are: NONE, CREATE, DROP, BOTH");
542       throw new MojoExecutionException("Invalid value for configuration-option \"type\"");
543     }
544
545     if (target.equals(Target.SCRIPT) || target.equals(Target.NONE))
546     {
547       project.getProperties().setProperty(EXPORT_SKIPPED_PROPERTY, "true");
548     }
549     if (
550         !modified
551         && !target.equals(Target.SCRIPT)
552         && !target.equals(Target.NONE)
553         && !force
554       )
555     {
556       getLog().info("No modified annotated classes found and dialect unchanged.");
557       getLog().info("Skipping schema generation!");
558       project.getProperties().setProperty(EXPORT_SKIPPED_PROPERTY, "true");
559       return;
560     }
561
562     getLog().info("Gathered hibernate-configuration (turn on debugging for details):");
563     for (Entry<Object,Object> entry : properties.entrySet())
564       getLog().info("  " + entry.getKey() + " = " + entry.getValue());
565
566     Connection connection = null;
567     try
568     {
569       /**
570        * The connection must be established outside of hibernate, because
571        * hibernate does not use the context-classloader of the current
572        * thread and, hence, would not be able to resolve the driver-class!
573        */
574       switch (target)
575       {
576         case EXPORT:
577         case BOTH:
578           switch (type)
579           {
580             case CREATE:
581             case DROP:
582             case BOTH:
583               Class driverClass = classLoader.loadClass(properties.getProperty(DRIVER_CLASS));
584               getLog().debug("Registering JDBC-driver " + driverClass.getName());
585               DriverManager.registerDriver(new DriverProxy((Driver)driverClass.newInstance()));
586               getLog().debug(
587                   "Opening JDBC-connection to "
588                   + properties.getProperty(URL)
589                   + " as "
590                   + properties.getProperty(USERNAME)
591                   + " with password "
592                   + properties.getProperty(PASSWORD)
593                   );
594               connection = DriverManager.getConnection(
595                   properties.getProperty(URL),
596                   properties.getProperty(USERNAME),
597                   properties.getProperty(PASSWORD)
598                   );
599           }
600       }
601     }
602     catch (ClassNotFoundException e)
603     {
604       getLog().error("Dependency for driver-class " + properties.getProperty(DRIVER_CLASS) + " is missing!");
605       throw new MojoExecutionException(e.getMessage());
606     }
607     catch (Exception e)
608     {
609       getLog().error("Cannot establish connection to database!");
610       Enumeration<Driver> drivers = DriverManager.getDrivers();
611       if (!drivers.hasMoreElements())
612         getLog().error("No drivers registered!");
613       while (drivers.hasMoreElements())
614         getLog().debug("Driver: " + drivers.nextElement());
615       throw new MojoExecutionException(e.getMessage());
616     }
617
618     ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
619     MavenLogAppender.startPluginLog(this);
620     try
621     {
622       /**
623        * Change class-loader of current thread, so that hibernate can
624        * see all dependencies!
625        */
626       Thread.currentThread().setContextClassLoader(classLoader);
627
628       SchemaExport export = new SchemaExport(config, connection);
629       export.setOutputFile(outputFile);
630       export.setDelimiter(delimiter);
631       export.setFormat(format);
632       export.execute(target, type);
633
634       for (Object exception : export.getExceptions())
635         getLog().debug(exception.toString());
636     }
637     finally
638     {
639       /** Stop Log-Capturing */
640       MavenLogAppender.endPluginLog(this);
641
642       /** Restore the old class-loader (TODO: is this really necessary?) */
643       Thread.currentThread().setContextClassLoader(contextClassLoader);
644
645       /** Close the connection */
646       try
647       {
648         if (connection != null)
649           connection.close();
650       }
651       catch (SQLException e)
652       {
653         getLog().error("Error while closing connection: " + e.getMessage());
654       }
655     }
656
657     /** Write md5-sums for annotated classes to file */
658     try
659     {
660       FileOutputStream fos = new FileOutputStream(saved);
661       ObjectOutputStream oos = new ObjectOutputStream(fos);
662       oos.writeObject(md5s);
663       oos.close();
664       fos.close();
665     }
666     catch (Exception e)
667     {
668       getLog().error("Cannot write md5-sums to file: " + e);
669     }
670   }
671
672   /**
673    * Needed, because DriverManager won't pick up drivers, that were not
674    * loaded by the system-classloader!
675    * See:
676    * http://stackoverflow.com/questions/288828/how-to-use-a-jdbc-driver-fromodifiedm-an-arbitrary-location
677    */
678   static final class DriverProxy implements Driver
679   {
680     private final Driver target;
681
682     DriverProxy(Driver target)
683     {
684       if (target == null)
685         throw new NullPointerException();
686       this.target = target;
687     }
688
689     public java.sql.Driver getTarget()
690     {
691       return target;
692     }
693
694     @Override
695     public boolean acceptsURL(String url) throws SQLException
696     {
697       return target.acceptsURL(url);
698     }
699
700     @Override
701     public java.sql.Connection connect(
702         String url,
703         java.util.Properties info
704       )
705       throws
706         SQLException
707     {
708       return target.connect(url, info);
709     }
710
711     @Override
712     public int getMajorVersion()
713     {
714       return target.getMajorVersion();
715     }
716
717     @Override
718     public int getMinorVersion()
719     {
720       return target.getMinorVersion();
721     }
722
723     @Override
724     public DriverPropertyInfo[] getPropertyInfo(
725         String url,
726         Properties info
727       )
728       throws
729         SQLException
730     {
731       return target.getPropertyInfo(url, info);
732     }
733
734     @Override
735     public boolean jdbcCompliant()
736     {
737       return target.jdbcCompliant();
738     }
739
740     /**
741      * This Method cannot be annotated with @Override, becaus the plugin
742      * will not compile then under Java 1.6!
743      */
744     public Logger getParentLogger() throws SQLFeatureNotSupportedException
745     {
746       throw new SQLFeatureNotSupportedException("Not supported, for backward-compatibility with Java 1.6");
747     }
748
749     @Override
750     public String toString()
751     {
752       return "Proxy: " + target;
753     }
754
755     @Override
756     public int hashCode()
757     {
758       return target.hashCode();
759     }
760
761     @Override
762     public boolean equals(Object obj)
763     {
764       if (!(obj instanceof DriverProxy))
765         return false;
766       DriverProxy other = (DriverProxy) obj;
767       return this.target.equals(other.target);
768     }
769   }
770 }