001package io.ebean.migration.runner;
002
003import io.ebean.ddlrunner.ScriptTransform;
004import io.ebean.migration.JdbcMigration;
005import io.ebean.migration.MigrationConfig;
006import io.ebean.migration.MigrationException;
007import io.ebean.migration.MigrationVersion;
008import io.ebean.migration.util.IOUtils;
009import org.slf4j.Logger;
010import org.slf4j.LoggerFactory;
011
012import java.io.IOException;
013import java.net.URL;
014import java.sql.Connection;
015import java.sql.DatabaseMetaData;
016import java.sql.ResultSet;
017import java.sql.SQLException;
018import java.sql.Timestamp;
019import java.util.ArrayList;
020import java.util.Enumeration;
021import java.util.LinkedHashMap;
022import java.util.List;
023import java.util.Set;
024
025import static io.ebean.migration.MigrationVersion.BOOTINIT_TYPE;
026import static io.ebean.migration.MigrationVersion.VERSION_TYPE;
027
028/**
029 * Manages the migration table.
030 */
031public class MigrationTable {
032
033  private static final Logger logger = LoggerFactory.getLogger("io.ebean.DDL");
034
035  private static final String INIT_VER_0 = "0";
036
037  private final Connection connection;
038  private final boolean checkState;
039  private final MigrationPlatform platform;
040  private final MigrationScriptRunner scriptRunner;
041  private final String catalog;
042  private final String schema;
043  private final String table;
044  private final String sqlTable;
045  private final String envUserName;
046  private final String platformName;
047
048  private final Timestamp runOn = new Timestamp(System.currentTimeMillis());
049
050  private final ScriptTransform scriptTransform;
051
052  private final String insertSql;
053  private final String updateSql;
054  private final String updateChecksumSql;
055
056  private final LinkedHashMap<String, MigrationMetaRow> migrations;
057  private final boolean skipChecksum;
058
059  private final Set<String> patchInsertVersions;
060  private final Set<String> patchResetChecksumVersions;
061  private final boolean allowErrorInRepeatable;
062
063  private final MigrationVersion minVersion;
064  private final String minVersionFailMessage;
065
066  private MigrationVersion currentVersion;
067  private MigrationMetaRow lastMigration;
068  private LocalMigrationResource priorVersion;
069
070  private final List<LocalMigrationResource> checkMigrations = new ArrayList<>();
071
072  /**
073   * Version of a dbinit script. When set this means all migration version less than this are ignored.
074   */
075  private MigrationVersion dbInitVersion;
076
077  /**
078   * Construct with server, configuration and jdbc connection (DB admin user).
079   */
080  public MigrationTable(MigrationConfig config, Connection connection, boolean checkState, MigrationPlatform platform) {
081    this.platform = platform;
082    this.connection = connection;
083    this.scriptRunner = new MigrationScriptRunner(connection, platform);
084    this.checkState = checkState;
085    this.migrations = new LinkedHashMap<>();
086    this.catalog = null;
087    this.allowErrorInRepeatable = config.isAllowErrorInRepeatable();
088    this.patchResetChecksumVersions = config.getPatchResetChecksumOn();
089    this.patchInsertVersions = config.getPatchInsertOn();
090    this.minVersion = initMinVersion(config.getMinVersion());
091    this.minVersionFailMessage = config.getMinVersionFailMessage();
092    this.skipChecksum = config.isSkipChecksum();
093    this.schema = config.getDbSchema();
094    this.table = config.getMetaTable();
095    this.platformName = config.getPlatformName();
096    this.sqlTable = initSqlTable();
097    this.insertSql = MigrationMetaRow.insertSql(sqlTable);
098    this.updateSql = MigrationMetaRow.updateSql(sqlTable);
099    this.updateChecksumSql = MigrationMetaRow.updateChecksumSql(sqlTable);
100    this.scriptTransform = createScriptTransform(config);
101    this.envUserName = System.getProperty("user.name");
102  }
103
104  private MigrationVersion initMinVersion(String minVersion) {
105    return (minVersion == null || minVersion.isEmpty()) ? null : MigrationVersion.parse(minVersion);
106  }
107
108  private String initSqlTable() {
109    if (schema != null) {
110      return schema + "." + table;
111    } else {
112      return table;
113    }
114  }
115
116  private String sqlPrimaryKey() {
117    return "pk_" + table;
118  }
119
120  /**
121   * Return the number of migrations in the DB migration table.
122   */
123  public int size() {
124    return migrations.size();
125  }
126
127  /**
128   * Returns the versions that are already applied.
129   */
130  public Set<String> getVersions() {
131    return migrations.keySet();
132  }
133
134  /**
135   * Create the ScriptTransform for placeholder key/value replacement.
136   */
137  private ScriptTransform createScriptTransform(MigrationConfig config) {
138    return ScriptTransform.build(config.getRunPlaceholders(), config.getRunPlaceholderMap());
139  }
140
141  /**
142   * Create the table is it does not exist.
143   * <p>
144   * Also holds DB lock on migration table and loads existing migrations.
145   * </p>
146   */
147  public void createIfNeededAndLock() throws SQLException, IOException {
148    if (!tableExists()) {
149      createTable();
150    }
151    obtainLockWithWait();
152    readExistingMigrations();
153  }
154
155  /**
156   * Obtain lock with wait, note that other nodes can insert and commit
157   * into the migration table during the wait so this query result won't
158   * contain all the executed migrations in that case.
159   */
160  private void obtainLockWithWait() throws SQLException {
161    platform.lockMigrationTable(sqlTable, connection);
162  }
163
164  /**
165   * Read the migration table with details on what migrations have run.
166   * This must execute after we have completed the wait for the lock on
167   * the migration table such that it reads any migrations that have
168   * executed during the wait for the lock.
169   */
170  private void readExistingMigrations() throws SQLException {
171    for (MigrationMetaRow metaRow : platform.readExistingMigrations(sqlTable, connection)) {
172      addMigration(metaRow.getVersion(), metaRow);
173    }
174  }
175
176  private void createTable() throws IOException, SQLException {
177    scriptRunner.runScript(createTableDdl(), "create migration table");
178    createInitMetaRow().executeInsert(connection, insertSql);
179  }
180
181  /**
182   * Return the create table script.
183   */
184  String createTableDdl() throws IOException {
185    String script = ScriptTransform.replace("${table}", sqlTable, getCreateTableScript());
186    return ScriptTransform.replace("${pk_table}", sqlPrimaryKey(), script);
187  }
188
189  /**
190   * Return the create table script.
191   */
192  private String getCreateTableScript() throws IOException {
193    // supply a script to override the default table create script
194    String script = readResource("migration-support/create-table.sql");
195    if (script == null && platformName != null && !platformName.isEmpty()) {
196      // look for platform specific create table
197      script = readResource("migration-support/" + platformName + "-create-table.sql");
198    }
199    if (script == null) {
200      // no, just use the default script
201      script = readResource("migration-support/default-create-table.sql");
202    }
203    return script;
204  }
205
206  private String readResource(String location) throws IOException {
207    Enumeration<URL> resources = getClassLoader().getResources(location);
208    if (resources.hasMoreElements()) {
209      URL url = resources.nextElement();
210      return IOUtils.readUtf8(url);
211    }
212    return null;
213  }
214
215  private ClassLoader getClassLoader() {
216    return Thread.currentThread().getContextClassLoader();
217  }
218
219  /**
220   * Return true if the table exists.
221   */
222  private boolean tableExists() throws SQLException {
223    String migTable = table;
224    DatabaseMetaData metaData = connection.getMetaData();
225    if (metaData.storesUpperCaseIdentifiers()) {
226      migTable = migTable.toUpperCase();
227    }
228    String checkCatalog = (catalog != null) ? catalog : connection.getCatalog();
229    String checkSchema = (schema != null) ? schema : connection.getSchema();
230    try (ResultSet tables = metaData.getTables(checkCatalog, checkSchema, migTable, null)) {
231      return tables.next();
232    }
233  }
234
235  /**
236   * Return true if the migration ran successfully and false if the migration failed.
237   */
238  private boolean shouldRun(LocalMigrationResource localVersion, LocalMigrationResource prior) throws SQLException {
239    if (prior != null && !localVersion.isRepeatable()) {
240      if (!migrationExists(prior)) {
241        logger.error("Migration {} requires prior migration {} which has not been run", localVersion.getVersion(), prior.getVersion());
242        return false;
243      }
244    }
245
246    MigrationMetaRow existing = migrations.get(localVersion.key());
247    if (!runMigration(localVersion, existing)) {
248      return false;
249    }
250
251    // migration was run successfully ...
252    priorVersion = localVersion;
253    return true;
254  }
255
256  /**
257   * Run the migration script.
258   *
259   * @param local    The local migration resource
260   * @param existing The information for this migration existing in the table
261   * @return True if the migrations should continue
262   */
263  private boolean runMigration(LocalMigrationResource local, MigrationMetaRow existing) throws SQLException {
264    String script = null;
265    int checksum;
266    if (local instanceof LocalDdlMigrationResource) {
267      script = convertScript(local.getContent());
268      checksum = Checksum.calculate(script);
269    } else {
270      checksum = ((LocalJdbcMigrationResource) local).getChecksum();
271    }
272
273    if (existing == null && patchInsertMigration(local, checksum)) {
274      return true;
275    }
276    if (existing != null && skipMigration(checksum, local, existing)) {
277      return true;
278    }
279    executeMigration(local, script, checksum, existing);
280    return true;
281  }
282
283  /**
284   * Return true if we 'patch history' inserting a DB migration without running it.
285   */
286  private boolean patchInsertMigration(LocalMigrationResource local, int checksum) throws SQLException {
287    if (patchInsertVersions != null && patchInsertVersions.contains(local.key())) {
288      logger.info("patch migration - insert into history {}", local.getLocation());
289      if (!checkState) {
290        insertIntoHistory(local, checksum, 0);
291      }
292      return true;
293    }
294    return false;
295  }
296
297  /**
298   * Return true if the migration should be skipped.
299   */
300  boolean skipMigration(int checksum, LocalMigrationResource local, MigrationMetaRow existing) throws SQLException {
301    boolean matchChecksum = (existing.getChecksum() == checksum);
302    if (matchChecksum) {
303      logger.trace("... skip unchanged migration {}", local.getLocation());
304      return true;
305
306    } else if (patchResetChecksum(existing, checksum)) {
307      logger.info("patch migration - reset checksum on {}", local.getLocation());
308      return true;
309
310    } else if (local.isRepeatable() || skipChecksum) {
311      // re-run the migration
312      return false;
313    } else {
314      throw new MigrationException("Checksum mismatch on migration " + local.getLocation());
315    }
316  }
317
318  /**
319   * Return true if the checksum is reset on the existing migration.
320   */
321  private boolean patchResetChecksum(MigrationMetaRow existing, int newChecksum) throws SQLException {
322    if (isResetOnVersion(existing.getVersion())) {
323      if (!checkState) {
324        existing.resetChecksum(newChecksum, connection, updateChecksumSql);
325      }
326      return true;
327    } else {
328      return false;
329    }
330  }
331
332  private boolean isResetOnVersion(String version) {
333    return patchResetChecksumVersions != null && patchResetChecksumVersions.contains(version);
334  }
335
336  /**
337   * Run a migration script as new migration or update on existing repeatable migration.
338   */
339  private void executeMigration(LocalMigrationResource local, String script, int checksum, MigrationMetaRow existing) throws SQLException {
340    if (checkState) {
341      checkMigrations.add(local);
342      // simulate the migration being run such that following migrations also match
343      addMigration(local.key(), createMetaRow(local, checksum, 1));
344      return;
345    }
346
347    logger.debug("run migration {}", local.getLocation());
348
349    long start = System.currentTimeMillis();
350    try {
351      if (local instanceof LocalDdlMigrationResource) {
352        scriptRunner.runScript(script, "run migration version: " + local.getVersion());
353      } else {
354        JdbcMigration migration = ((LocalJdbcMigrationResource) local).getMigration();
355        logger.info("Executing jdbc migration version: {} - {}", local.getVersion(), migration);
356        migration.migrate(connection);
357      }
358      long exeMillis = System.currentTimeMillis() - start;
359
360      if (existing != null) {
361        existing.rerun(checksum, exeMillis, envUserName, runOn);
362        existing.executeUpdate(connection, updateSql);
363
364      } else {
365        insertIntoHistory(local, checksum, exeMillis);
366      }
367    } catch (SQLException e) {
368      if (allowErrorInRepeatable && local.isRepeatableLast()) {
369        // log the exception and continue on repeatable migration
370        logger.error("Continue migration with error executing repeatable migration " + local.getVersion(), e);
371      } else {
372        throw e;
373      }
374    }
375  }
376
377  private void insertIntoHistory(LocalMigrationResource local, int checksum, long exeMillis) throws SQLException {
378    MigrationMetaRow metaRow = createMetaRow(local, checksum, exeMillis);
379    metaRow.executeInsert(connection, insertSql);
380    addMigration(local.key(), metaRow);
381  }
382
383  private MigrationMetaRow createInitMetaRow() {
384    return new MigrationMetaRow(0, "I", INIT_VER_0, "<init>", 0, envUserName, runOn, 0);
385  }
386
387  /**
388   * Create the MigrationMetaRow for this migration.
389   */
390  private MigrationMetaRow createMetaRow(LocalMigrationResource migration, int checksum, long exeMillis) {
391
392    int nextId = 1;
393    if (lastMigration != null) {
394      nextId = lastMigration.getId() + 1;
395    }
396
397    String type = migration.getType();
398    String runVersion = migration.key();
399    String comment = migration.getComment();
400
401    return new MigrationMetaRow(nextId, type, runVersion, comment, checksum, envUserName, runOn, exeMillis);
402  }
403
404  /**
405   * Return true if the migration exists.
406   */
407  private boolean migrationExists(LocalMigrationResource priorVersion) {
408    return migrations.containsKey(priorVersion.key());
409  }
410
411  /**
412   * Apply the placeholder key/value replacement on the script.
413   */
414  private String convertScript(String script) {
415    return scriptTransform.transform(script);
416  }
417
418  /**
419   * Register the successfully executed migration (to allow dependant scripts to run).
420   */
421  private void addMigration(String key, MigrationMetaRow metaRow) {
422    if (INIT_VER_0.equals(key)) {
423      // ignore the version 0 <init> row
424      return;
425    }
426    lastMigration = metaRow;
427    if (metaRow.getVersion() == null) {
428      throw new IllegalStateException("No runVersion in db migration table row? " + metaRow);
429    }
430
431    migrations.put(key, metaRow);
432    if (VERSION_TYPE.equals(metaRow.getType()) || BOOTINIT_TYPE.equals(metaRow.getType())) {
433      MigrationVersion rowVersion = MigrationVersion.parse(metaRow.getVersion());
434      if (currentVersion == null || rowVersion.compareTo(currentVersion) > 0) {
435        currentVersion = rowVersion;
436      }
437      if (BOOTINIT_TYPE.equals(metaRow.getType())) {
438        dbInitVersion = rowVersion;
439      }
440    }
441  }
442
443  /**
444   * Return true if there are no migrations.
445   */
446  public boolean isEmpty() {
447    return migrations.isEmpty();
448  }
449
450  /**
451   * Run all the migrations in order as needed.
452   *
453   * @return the migrations that have been run (collected if checkstate is true).
454   */
455  public List<LocalMigrationResource> runAll(List<LocalMigrationResource> localVersions) throws SQLException {
456
457    checkMinVersion();
458    for (LocalMigrationResource localVersion : localVersions) {
459      if (!localVersion.isRepeatable() && dbInitVersion != null && dbInitVersion.compareTo(localVersion.getVersion()) >= 0) {
460        logger.debug("migration skipped by dbInitVersion {}", dbInitVersion);
461      } else if (!shouldRun(localVersion, priorVersion)) {
462        break;
463      }
464    }
465    return checkMigrations;
466  }
467
468  private void checkMinVersion() {
469    if (minVersion != null && currentVersion != null && currentVersion.compareTo(minVersion) < 0) {
470      StringBuilder sb = new StringBuilder();
471      if (minVersionFailMessage != null && !minVersionFailMessage.isEmpty()) {
472        sb.append(minVersionFailMessage).append(' ');
473      }
474      sb.append("MigrationVersion mismatch: v").append(currentVersion).append(" < v").append(minVersion);
475      throw new MigrationException(sb.toString());
476    }
477  }
478
479  /**
480   * Run using an init migration.
481   *
482   * @return the migrations that have been run (collected if checkstate is true).
483   */
484  public List<LocalMigrationResource> runInit(LocalMigrationResource initVersion, List<LocalMigrationResource> localVersions) throws SQLException {
485
486    runRepeatableInit(localVersions);
487
488    initVersion.setInitType();
489    if (!shouldRun(initVersion, null)) {
490      throw new IllegalStateException("Expected to run init migration but it didn't?");
491    }
492
493    // run any migrations greater that the init migration
494    for (LocalMigrationResource localVersion : localVersions) {
495      if (localVersion.compareTo(initVersion) > 0 && !shouldRun(localVersion, priorVersion)) {
496        break;
497      }
498    }
499    return checkMigrations;
500  }
501
502  private void runRepeatableInit(List<LocalMigrationResource> localVersions) throws SQLException {
503    for (LocalMigrationResource localVersion : localVersions) {
504      if (!localVersion.isRepeatableInit() || !shouldRun(localVersion, priorVersion)) {
505        break;
506      }
507    }
508  }
509
510  /**
511   * Run non transactional statements (if any) after migration commit.
512   * <p>
513   * These run with auto commit true and run AFTER the migration commit and
514   * as such the migration isn't truely atomic - the migration can run and
515   * complete and the non-transactional statements fail.
516   */
517  public void runNonTransactional() {
518    scriptRunner.runNonTransactional();
519  }
520}