001package io.ebean.migration.runner;
002
003import io.ebean.migration.MigrationConfig;
004import io.ebean.migration.MigrationException;
005import io.ebean.migration.util.IOUtils;
006import io.ebean.migration.util.JdbcClose;
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.Map;
023import java.util.Set;
024
025/**
026 * Manages the migration table.
027 */
028public class MigrationTable {
029
030  private static final Logger logger = LoggerFactory.getLogger(MigrationTable.class);
031
032  private final Connection connection;
033  private final boolean checkState;
034
035  private final String catalog;
036  private final String schema;
037  private final String table;
038  private final String sqlTable;
039  private final String envUserName;
040  private final String platformName;
041
042  private final Timestamp runOn = new Timestamp(System.currentTimeMillis());
043
044  private final ScriptTransform scriptTransform;
045
046  private final String insertSql;
047  private final String updateSql;
048  private final String updateChecksumSql;
049  private final String selectSql;
050
051  private final LinkedHashMap<String, MigrationMetaRow> migrations;
052  private final boolean skipChecksum;
053
054  private final Set<String> patchInsertVersions;
055  private final Set<String> patchResetChecksumVersions;
056
057  private MigrationMetaRow lastMigration;
058
059  private final List<LocalMigrationResource> checkMigrations = new ArrayList<>();
060
061  /**
062   * Construct with server, configuration and jdbc connection (DB admin user).
063   */
064  public MigrationTable(MigrationConfig config, Connection connection, boolean checkState) {
065
066    this.connection = connection;
067    this.checkState = checkState;
068    this.migrations = new LinkedHashMap<>();
069
070    this.catalog = null;
071    this.patchResetChecksumVersions = config.getPatchResetChecksumOn();
072    this.patchInsertVersions = config.getPatchInsertOn();
073    this.skipChecksum = config.isSkipChecksum();
074    this.schema = config.getDbSchema();
075    this.table = config.getMetaTable();
076    this.platformName = config.getPlatformName();
077    this.sqlTable = sqlTable();
078    this.selectSql = MigrationMetaRow.selectSql(sqlTable, platformName);
079    this.insertSql = MigrationMetaRow.insertSql(sqlTable);
080    this.updateSql = MigrationMetaRow.updateSql(sqlTable);
081    this.updateChecksumSql = MigrationMetaRow.updateChecksumSql(sqlTable);
082    this.scriptTransform = createScriptTransform(config);
083    this.envUserName = System.getProperty("user.name");
084  }
085
086  /**
087   * Return the migrations that have been run.
088   */
089  public List<LocalMigrationResource> ran() {
090    return checkMigrations;
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   * Create the ScriptTransform for placeholder key/value replacement.
114   */
115  private ScriptTransform createScriptTransform(MigrationConfig config) {
116
117    Map<String, String> map = PlaceholderBuilder.build(config.getRunPlaceholders(), config.getRunPlaceholderMap());
118    return new ScriptTransform(map);
119  }
120
121  /**
122   * Create the table is it does not exist.
123   * <p>
124   * Also holds DB lock on migration table and loads existing migrations.
125   * </p>
126   */
127  public void createIfNeededAndLock() throws SQLException, IOException {
128
129    if (!tableExists(connection)) {
130      createTable(connection);
131    }
132
133    // load existing migrations, hold DB lock on migration table
134    PreparedStatement query = connection.prepareStatement(selectSql);
135    try {
136      ResultSet resultSet = query.executeQuery();
137      try {
138        while (resultSet.next()) {
139          MigrationMetaRow metaRow = new MigrationMetaRow(resultSet);
140          addMigration(metaRow.getVersion(), metaRow);
141        }
142      } finally {
143        JdbcClose.close(resultSet);
144      }
145    } finally {
146      JdbcClose.close(query);
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.openStream());
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    ResultSet tables = metaData.getTables(checkCatalog, checkSchema, migTable, null);
210    try {
211      return tables.next();
212    } finally {
213      JdbcClose.close(tables);
214    }
215  }
216
217  /**
218   * Return true if the migration ran successfully and false if the migration failed.
219   */
220  public boolean shouldRun(LocalMigrationResource localVersion, LocalMigrationResource priorVersion) throws SQLException {
221
222    if (priorVersion != null && !localVersion.isRepeatable()) {
223      if (!migrationExists(priorVersion)) {
224        logger.error("Migration {} requires prior migration {} which has not been run", localVersion.getVersion(), priorVersion.getVersion());
225        return false;
226      }
227    }
228
229    MigrationMetaRow existing = migrations.get(localVersion.key());
230    return runMigration(localVersion, existing);
231  }
232
233  /**
234   * Run the migration script.
235   *
236   * @param local    The local migration resource
237   * @param existing The information for this migration existing in the table
238   * @return True if the migrations should continue
239   */
240  private boolean runMigration(LocalMigrationResource local, MigrationMetaRow existing) throws SQLException {
241
242    String script = null;
243    int checksum;
244    if (local instanceof LocalDdlMigrationResource) {
245      script = convertScript(((LocalDdlMigrationResource) local).getContent());
246      checksum = Checksum.calculate(script);
247    } else {
248      checksum = ((LocalJdbcMigrationResource) local).getChecksum();
249    }
250
251    if (existing == null && patchInsertMigration(local, checksum)) {
252      return true;
253    }
254    if (existing != null && skipMigration(checksum, local, existing)) {
255      return true;
256    }
257    executeMigration(local, script, checksum, existing);
258    return true;
259  }
260
261  /**
262   * Return true if we 'patch history' inserting a DB migration without running it.
263   */
264  private boolean patchInsertMigration(LocalMigrationResource local, int checksum) throws SQLException {
265    if (patchInsertVersions != null && patchInsertVersions.contains(local.key())) {
266      logger.info("patch migration - insert into history {}", local.getLocation());
267      if (!checkState) {
268        insertIntoHistory(local, checksum, 0);
269      }
270      return true;
271    }
272    return false;
273  }
274
275  /**
276   * Return true if the migration should be skipped.
277   */
278  boolean skipMigration(int checksum, LocalMigrationResource local, MigrationMetaRow existing) throws SQLException {
279
280    boolean matchChecksum = (existing.getChecksum() == checksum);
281    if (matchChecksum) {
282      logger.trace("... skip unchanged migration {}", local.getLocation());
283      return true;
284
285    } else if (patchResetChecksum(existing, checksum)) {
286      logger.info("patch migration - reset checksum on {}", local.getLocation());
287      return true;
288
289    } else if (local.isRepeatable() || skipChecksum) {
290      // re-run the migration
291      return false;
292    } else {
293      throw new MigrationException("Checksum mismatch on migration " + local.getLocation());
294    }
295  }
296
297  /**
298   * Return true if the checksum is reset on the existing migration.
299   */
300  private boolean patchResetChecksum(MigrationMetaRow existing, int newChecksum) throws SQLException {
301
302    if (isResetOnVersion(existing.getVersion())) {
303      if (!checkState) {
304        existing.resetChecksum(newChecksum, connection, updateChecksumSql);
305      }
306      return true;
307    } else {
308      return false;
309    }
310  }
311
312  private boolean isResetOnVersion(String version) {
313    return patchResetChecksumVersions != null && patchResetChecksumVersions.contains(version);
314  }
315
316  /**
317   * Run a migration script as new migration or update on existing repeatable migration.
318   */
319  private void executeMigration(LocalMigrationResource local, String script, int checksum, MigrationMetaRow existing) throws SQLException {
320
321    if (checkState) {
322      checkMigrations.add(local);
323      // simulate the migration being run such that following migrations also match
324      addMigration(local.key(), createMetaRow(local, checksum, 1));
325      return;
326    }
327
328    logger.debug("run migration {}", local.getLocation());
329
330    long start = System.currentTimeMillis();
331    if (local instanceof LocalDdlMigrationResource) {
332      MigrationScriptRunner run = new MigrationScriptRunner(connection);
333      run.runScript(false, script, "run migration version: " + local.getVersion());
334    } else {
335      ((LocalJdbcMigrationResource)local).getMigration().migrate(connection);
336    }
337    long exeMillis = System.currentTimeMillis() - start;
338
339    if (existing != null) {
340      existing.rerun(checksum, exeMillis, envUserName, runOn);
341      existing.executeUpdate(connection, updateSql);
342
343    } else {
344      insertIntoHistory(local, checksum, exeMillis);
345    }
346  }
347
348  private void insertIntoHistory(LocalMigrationResource local, int checksum, long exeMillis) throws SQLException {
349    MigrationMetaRow metaRow = createMetaRow(local, checksum, exeMillis);
350    metaRow.executeInsert(connection, insertSql);
351    addMigration(local.key(), metaRow);
352  }
353
354  /**
355   * Create the MigrationMetaRow for this migration.
356   */
357  private MigrationMetaRow createMetaRow(LocalMigrationResource migration, int checksum, long exeMillis) {
358
359    int nextId = 1;
360    if (lastMigration != null) {
361      nextId = lastMigration.getId() + 1;
362    }
363
364    String type = migration.getType();
365    String runVersion = migration.key();
366    String comment = migration.getComment();
367
368    return new MigrationMetaRow(nextId, type, runVersion, comment, checksum, envUserName, runOn, exeMillis);
369  }
370
371  /**
372   * Return true if the migration exists.
373   */
374  private boolean migrationExists(LocalMigrationResource priorVersion) {
375    return migrations.containsKey(priorVersion.key());
376  }
377
378  /**
379   * Apply the placeholder key/value replacement on the script.
380   */
381  private String convertScript(String script) {
382    return scriptTransform.transform(script);
383  }
384
385  /**
386   * Register the successfully executed migration (to allow dependant scripts to run).
387   */
388  private void addMigration(String key, MigrationMetaRow metaRow) {
389    lastMigration = metaRow;
390    if (metaRow.getVersion() == null) {
391      throw new IllegalStateException("No runVersion in db migration table row? " + metaRow);
392    }
393    migrations.put(key, metaRow);
394  }
395}