001package com.avaje.ebean.dbmigration;
002
003import com.avaje.ebean.Ebean;
004import com.avaje.ebean.EbeanServer;
005import com.avaje.ebean.config.DbConstraintNaming;
006import com.avaje.ebean.config.DbMigrationConfig;
007import com.avaje.ebean.config.ServerConfig;
008import com.avaje.ebean.config.dbplatform.DB2Platform;
009import com.avaje.ebean.config.dbplatform.DatabasePlatform;
010import com.avaje.ebean.config.dbplatform.DbPlatformName;
011import com.avaje.ebean.config.dbplatform.H2Platform;
012import com.avaje.ebean.config.dbplatform.MsSqlServer2005Platform;
013import com.avaje.ebean.config.dbplatform.MySqlPlatform;
014import com.avaje.ebean.config.dbplatform.OraclePlatform;
015import com.avaje.ebean.config.dbplatform.PostgresPlatform;
016import com.avaje.ebean.config.dbplatform.SQLitePlatform;
017import com.avaje.ebean.dbmigration.ddlgeneration.DdlWrite;
018import com.avaje.ebean.dbmigration.migration.Migration;
019import com.avaje.ebean.dbmigration.migrationreader.MigrationXmlWriter;
020import com.avaje.ebean.dbmigration.model.CurrentModel;
021import com.avaje.ebean.dbmigration.model.MConfiguration;
022import com.avaje.ebean.dbmigration.model.MigrationModel;
023import com.avaje.ebean.dbmigration.model.MigrationVersion;
024import com.avaje.ebean.dbmigration.model.ModelContainer;
025import com.avaje.ebean.dbmigration.model.ModelDiff;
026import com.avaje.ebean.dbmigration.model.PlatformDdlWriter;
027import com.avaje.ebeaninternal.api.SpiEbeanServer;
028import org.slf4j.Logger;
029import org.slf4j.LoggerFactory;
030
031import java.io.File;
032import java.io.IOException;
033import java.util.ArrayList;
034import java.util.List;
035
036/**
037 * Generates DB Migration xml and sql scripts.
038 * <p>
039 * Reads the prior migrations and compares with the current model of the EbeanServer
040 * and generates a migration 'diff' in the form of xml document with the logical schema
041 * changes and a series of sql scripts to apply, rollback the applied changes if necessary
042 * and drop objects (drop tables, drop columns).
043 * </p>
044 * <p>
045 *   This does not run the migration or ddl scripts but just generates them.
046 * </p>
047 * <pre>{@code
048 *
049 *       DbMigration migration = new DbMigration();
050 *       migration.setPathToResources("src/main/resources");
051 *       migration.setPlatform(DbPlatformName.ORACLE);
052 *
053 *       migration.generateMigration();
054 *
055 * }</pre>
056 */
057public class DbMigration {
058
059  protected static final Logger logger = LoggerFactory.getLogger(DbMigration.class);
060
061  private static final String initialVersion = "1.0";
062
063  private static final String GENERATED_COMMENT = "THIS IS A GENERATED FILE - DO NOT MODIFY";
064
065  /**
066   * Set to true if DbMigration run with online EbeanServer instance.
067   */
068  protected final boolean online;
069
070  protected SpiEbeanServer server;
071
072  protected DbMigrationConfig migrationConfig;
073
074  protected String pathToResources = "src/main/resources";
075
076  protected DatabasePlatform databasePlatform;
077
078  protected List<Pair> platforms = new ArrayList<Pair>();
079
080  protected ServerConfig serverConfig;
081
082  protected DbConstraintNaming constraintNaming;
083
084  /**
085   * Create for offline migration generation.
086   */
087  public DbMigration() {
088    this.online = false;
089  }
090
091  /**
092   * Create using online EbeanServer.
093   */
094  public DbMigration(EbeanServer server) {
095    this.online = true;
096    setServer(server);
097  }
098
099  /**
100   * Set the path from the current working directory to the application resources.
101   *
102   * This defaults to maven style 'src/main/resources'.
103   */
104  public void setPathToResources(String pathToResources) {
105    this.pathToResources = pathToResources;
106  }
107
108  /**
109   * Set the server to use to determine the current model.
110   * Typically this is not called explicitly.
111   */
112  public void setServer(EbeanServer ebeanServer) {
113    this.server = (SpiEbeanServer) ebeanServer;
114    setServerConfig(server.getServerConfig());
115  }
116
117  /**
118   * Set the serverConfig to use. Typically this is not called explicitly.
119   */
120  public void setServerConfig(ServerConfig config) {
121    if (this.serverConfig == null) {
122      this.serverConfig = config;
123    }
124    if (migrationConfig == null) {
125      this.migrationConfig = serverConfig.getMigrationConfig();
126    }
127    if (constraintNaming == null) {
128      this.constraintNaming = serverConfig.getConstraintNaming();
129    }
130  }
131
132  /**
133   * Set the specific platform to generate DDL for.
134   * <p>
135   * If not set this defaults to the platform of the default server.
136   * </p>
137   */
138  public void setPlatform(DbPlatformName platform) {
139    setPlatform(getPlatform(platform));
140  }
141
142  /**
143   * Set the specific platform to generate DDL for.
144   * <p>
145   * If not set this defaults to the platform of the default server.
146   * </p>
147   */
148  public void setPlatform(DatabasePlatform databasePlatform) {
149    this.databasePlatform = databasePlatform;
150    if (!online) {
151      DbOffline.setPlatform(databasePlatform.getName());
152    }
153  }
154
155  /**
156   * Add an additional platform to write the migration DDL.
157   * <p>
158   * Use this when you want to generate sql scripts for multiple database platforms
159   * from the migration (e.g. generate migration sql for MySql, Postgres and Oracle).
160   * </p>
161   */
162  public void addPlatform(DbPlatformName platform, String prefix) {
163    if (!prefix.endsWith("-")) {
164      prefix += "-";
165    }
166    platforms.add(new Pair(getPlatform(platform), prefix));
167  }
168
169  /**
170   * Generate the next migration xml file and associated apply and rollback sql scripts.
171   * <p>
172   *   This does not run the migration or ddl scripts but just generates them.
173   * </p>
174   * <h3>Example: Run for a single specific platform</h3>
175   * <pre>{@code
176   *
177   *       DbMigration migration = new DbMigration();
178   *       migration.setPathToResources("src/main/resources");
179   *       migration.setPlatform(DbPlatformName.ORACLE);
180   *
181   *       migration.generateMigration();
182   *
183   * }</pre>
184   *
185   * <h3>Example: Run migration generating DDL for multiple platforms</h3>
186   * <pre>{@code
187   *
188   *       DbMigration migration = new DbMigration();
189   *       migration.setPathToResources("src/main/resources");
190   *
191   *       migration.addPlatform(DbPlatformName.POSTGRES, "pg");
192   *       migration.addPlatform(DbPlatformName.MYSQL, "mysql");
193   *       migration.addPlatform(DbPlatformName.ORACLE, "mysql");
194   *
195   *       migration.generateMigration();
196   *
197   * }</pre>
198   */
199  public void generateMigration() throws IOException {
200
201    // use this flag to stop other plugins like full DDL generation
202    if (!online) {
203      DbOffline.setRunningMigration();
204    }
205    setDefaults();
206    try {
207      Request request = createRequest();
208
209      String pendingVersion = generatePendingDrop();
210      if (pendingVersion != null) {
211        generatePendingDrop(request, pendingVersion);
212      } else {
213        generateDiff(request);
214      }
215
216    } finally {
217      if (!online) {
218        DbOffline.reset();
219      }
220    }
221  }
222
223  /**
224   * Generate the diff migration.
225   */
226  private void generateDiff(Request request) throws IOException {
227
228    List<String> pendingDrops = request.getPendingDrops();
229    if (!pendingDrops.isEmpty()) {
230      logger.info("Pending un-applied drops in versions {}", pendingDrops);
231    }
232
233    Migration migration = request.createDiffMigration();
234    if (migration == null) {
235      logger.info("no changes detected - no migration written");
236    } else {
237      // there were actually changes to write
238      generateMigration(request, migration, null);
239    }
240  }
241
242  /**
243   * Generate the migration based on the pendingDrops from a prior version.
244   */
245  private void generatePendingDrop(Request request, String pendingVersion) throws IOException {
246
247    Migration migration = request.migrationForPendingDrop(pendingVersion);
248
249    generateMigration(request, migration, pendingVersion);
250
251    List<String> pendingDrops = request.getPendingDrops();
252    if (!pendingDrops.isEmpty()) {
253      logger.info("... remaining pending un-applied drops in versions {}", pendingDrops);
254    }
255  }
256
257  private Request createRequest() {
258    return new Request();
259  }
260
261  private class Request {
262
263    final File migrationDir;
264    final File modelDir;
265    final MigrationModel migrationModel;
266    final CurrentModel currentModel;
267    final ModelContainer migrated;
268    final ModelContainer current;
269
270    private Request() {
271      this.migrationDir = getMigrationDirectory();
272      this.modelDir = getModelDirectory(migrationDir);
273      this.migrationModel = new MigrationModel(modelDir, migrationConfig.getModelSuffix());
274      this.migrated = migrationModel.read();
275      this.currentModel = new CurrentModel(server, constraintNaming);
276      this.current = currentModel.read();
277    }
278
279    /**
280     * Return the migration for the pending drops for a given version.
281     */
282    public Migration migrationForPendingDrop(String pendingVersion) {
283
284      Migration migration = migrated.migrationForPendingDrop(pendingVersion);
285
286      // register any remaining pending drops
287      migrated.registerPendingHistoryDropColumns(current);
288      return migration;
289    }
290
291    /**
292     * Return the list of versions that have pending un-applied drops.
293     */
294    public List<String> getPendingDrops() {
295      return migrated.getPendingDrops();
296    }
297
298    /**
299     * Create and return the diff of the current model to the migration model.
300     */
301    public Migration createDiffMigration() {
302      ModelDiff diff = new ModelDiff(migrated);
303      diff.compareTo(current);
304      return diff.isEmpty() ? null : diff.getMigration();
305    }
306  }
307
308  private void generateMigration(Request request, Migration dbMigration, String dropsFor) throws IOException {
309
310    String fullVersion = getFullVersion(request.migrationModel, dropsFor);
311
312    logger.info("generating migration:{}", fullVersion);
313    if (!writeMigrationXml(dbMigration, request.modelDir, fullVersion)) {
314      logger.warn("migration already exists, not generating DDL");
315
316    } else {
317      if (databasePlatform != null) {
318        // writer needs the current model to provide table/column details for
319        // history ddl generation (triggers, history tables etc)
320        DdlWrite write = new DdlWrite(new MConfiguration(), request.current);
321        PlatformDdlWriter writer = createDdlWriter(databasePlatform, "");
322        writer.processMigration(dbMigration, write, request.migrationDir , fullVersion);
323      }
324      writeExtraPlatformDdl(fullVersion, request.currentModel, dbMigration, request.migrationDir);
325    }
326  }
327
328  /**
329   * Return true if the next pending drop changeSet should be generated as the next migration.
330   */
331  private String generatePendingDrop() {
332
333    String nextDrop = System.getProperty("ddl.migration.pendingDropsFor");
334    if (nextDrop != null) {
335      return nextDrop;
336    }
337    return migrationConfig.getGeneratePendingDrop();
338  }
339
340  /**
341   * Return the full version for the migration being generated.
342   *
343   * The full version can contain a comment suffix after a "__" double underscore.
344   */
345  private String getFullVersion(MigrationModel migrationModel, String dropsFor) {
346
347    String version = migrationConfig.getVersion();
348    if (version == null) {
349      version = migrationModel.getNextVersion(initialVersion);
350    }
351
352    String fullVersion = version;
353    if (migrationConfig.getName() != null) {
354      fullVersion += "__" + toUnderScore(migrationConfig.getName());
355
356    } else if (dropsFor != null) {
357      fullVersion += "__" + toUnderScore("dropsFor_" + MigrationVersion.trim(dropsFor));
358
359    } else if (version.equals(initialVersion)) {
360      fullVersion += "__initial";
361    }
362    return fullVersion;
363  }
364
365  /**
366   * Replace spaces with underscores.
367   */
368  private String toUnderScore(String name) {
369    return name.replace(' ','_');
370  }
371
372  /**
373   * Write any extra platform ddl.
374   */
375  protected void writeExtraPlatformDdl(String fullVersion, CurrentModel currentModel, Migration dbMigration, File writePath) throws IOException {
376
377    for (Pair pair : platforms) {
378      DdlWrite platformBuffer = new DdlWrite(new MConfiguration(), currentModel.read());
379      PlatformDdlWriter platformWriter = createDdlWriter(pair);
380      platformWriter.processMigration(dbMigration, platformBuffer, writePath, fullVersion);
381    }
382  }
383
384  private PlatformDdlWriter createDdlWriter(Pair pair) {
385    return createDdlWriter(pair.platform, pair.prefix);
386  }
387
388  private PlatformDdlWriter createDdlWriter(DatabasePlatform platform, String prefix) {
389    return new PlatformDdlWriter(platform, serverConfig, prefix, migrationConfig);
390  }
391
392  /**
393   * Write the migration xml.
394   */
395  protected boolean writeMigrationXml(Migration dbMigration, File resourcePath, String fullVersion) {
396
397    String modelFile = fullVersion + migrationConfig.getModelSuffix();
398    File file = new File(resourcePath, modelFile);
399    if (file.exists()) {
400      return false;
401    }
402    String comment = migrationConfig.isIncludeGeneratedFileComment() ? GENERATED_COMMENT : null;
403    MigrationXmlWriter xmlWriter = new MigrationXmlWriter(comment);
404    xmlWriter.write(dbMigration, file);
405    return true;
406  }
407
408  /**
409   * Set default server and platform if necessary.
410   */
411  protected void setDefaults() {
412    if (server == null) {
413      setServer(Ebean.getDefaultServer());
414    }
415    if (databasePlatform == null && platforms.isEmpty()) {
416      // not explicitly set not set a list of platforms so
417      // default to the platform of the default server
418      databasePlatform = server.getDatabasePlatform();
419      logger.debug("set platform to {}", databasePlatform.getName());
420    }
421  }
422
423  /**
424   * Return the file path to write the xml and sql to.
425   */
426  protected File getMigrationDirectory() {
427
428    // path to src/main/resources in typical maven project
429    File resourceRootDir = new File(pathToResources);
430    String resourcePath = migrationConfig.getMigrationPath();
431
432    // expect to be a path to something like - src/main/resources/dbmigration/model
433    File path = new File(resourceRootDir, resourcePath);
434    if (!path.exists()) {
435      if (!path.mkdirs()) {
436        logger.debug("Unable to ensure migration directory exists at {}", path.getAbsolutePath());
437      }
438    }
439    return path;
440  }
441
442  /**
443   * Return the model directory (relative to the migration directory).
444   */
445  protected File getModelDirectory(File migrationDirectory) {
446    String modelPath = migrationConfig.getModelPath();
447    if (modelPath ==  null || modelPath.isEmpty()) {
448      return migrationDirectory;
449    }
450    File modelDir = new File(migrationDirectory, migrationConfig.getModelPath());
451    if (!modelDir.exists() && !modelDir.mkdirs()) {
452      logger.debug("Unable to ensure migration model directory exists at {}", modelDir.getAbsolutePath());
453    }
454    return modelDir;
455  }
456
457  /**
458   * Return the DatabasePlatform given the platform key.
459   */
460  protected DatabasePlatform getPlatform(DbPlatformName platform) {
461    switch (platform) {
462      case H2:
463        return new H2Platform();
464      case POSTGRES:
465        return new PostgresPlatform();
466      case MYSQL:
467        return new MySqlPlatform();
468      case ORACLE:
469        return new OraclePlatform();
470      case SQLSERVER:
471        return new MsSqlServer2005Platform();
472      case DB2:
473        return new DB2Platform();
474      case SQLITE:
475        return new SQLitePlatform();
476
477      default:
478        throw new IllegalArgumentException("Platform missing? " + platform);
479    }
480  }
481
482  /**
483   * Holds a platform and prefix. Used to generate multiple platform specific DDL
484   * for a single migration.
485   */
486  public static class Pair {
487
488    /**
489     * The platform to generate the DDL for.
490     */
491    public final DatabasePlatform platform;
492
493    /**
494     * A prefix included into the file/resource names indicating the platform.
495     */
496    public final String prefix;
497
498    public Pair(DatabasePlatform platform, String prefix) {
499      this.platform = platform;
500      this.prefix = prefix;
501    }
502  }
503
504}