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