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