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 = convertScript(local.getContent());
243    int checksum = Checksum.calculate(script);
244
245    if (existing == null && patchInsertMigration(local, checksum)) {
246      return true;
247    }
248    if (existing != null && skipMigration(checksum, local, existing)) {
249      return true;
250    }
251    executeMigration(local, script, checksum, existing);
252    return true;
253  }
254
255  /**
256   * Return true if we 'patch history' inserting a DB migration without running it.
257   */
258  private boolean patchInsertMigration(LocalMigrationResource local, int checksum) throws SQLException {
259    if (patchInsertVersions != null && patchInsertVersions.contains(local.key())) {
260      logger.info("patch migration - insert into history {}", local.getLocation());
261      if (!checkState) {
262        insertIntoHistory(local, checksum, 0);
263      }
264      return true;
265    }
266    return false;
267  }
268
269  /**
270   * Return true if the migration should be skipped.
271   */
272  boolean skipMigration(int checksum, LocalMigrationResource local, MigrationMetaRow existing) throws SQLException {
273
274    boolean matchChecksum = (existing.getChecksum() == checksum);
275    if (matchChecksum) {
276      logger.trace("... skip unchanged migration {}", local.getLocation());
277      return true;
278
279    } else if (patchResetChecksum(existing, checksum)) {
280      logger.info("patch migration - reset checksum on {}", local.getLocation());
281      return true;
282
283    } else if (local.isRepeatable() || skipChecksum) {
284      // re-run the migration
285      return false;
286    } else {
287      throw new MigrationException("Checksum mismatch on migration " + local.getLocation());
288    }
289  }
290
291  /**
292   * Return true if the checksum is reset on the existing migration.
293   */
294  private boolean patchResetChecksum(MigrationMetaRow existing, int newChecksum) throws SQLException {
295
296    if (isResetOnVersion(existing.getVersion())) {
297      if (!checkState) {
298        existing.resetChecksum(newChecksum, connection, updateChecksumSql);
299      }
300      return true;
301    } else {
302      return false;
303    }
304  }
305
306  private boolean isResetOnVersion(String version) {
307    return patchResetChecksumVersions != null && patchResetChecksumVersions.contains(version);
308  }
309
310  /**
311   * Run a migration script as new migration or update on existing repeatable migration.
312   */
313  private void executeMigration(LocalMigrationResource local, String script, int checksum, MigrationMetaRow existing) throws SQLException {
314
315    if (checkState) {
316      checkMigrations.add(local);
317      // simulate the migration being run such that following migrations also match
318      addMigration(local.key(), createMetaRow(local, checksum, 1));
319      return;
320    }
321
322    logger.debug("run migration {}", local.getLocation());
323
324    long start = System.currentTimeMillis();
325    MigrationScriptRunner run = new MigrationScriptRunner(connection);
326    run.runScript(false, script, "run migration version: " + local.getVersion());
327
328    long exeMillis = System.currentTimeMillis() - start;
329
330    if (existing != null) {
331      existing.rerun(checksum, exeMillis, envUserName, runOn);
332      existing.executeUpdate(connection, updateSql);
333
334    } else {
335      insertIntoHistory(local, checksum, exeMillis);
336    }
337  }
338
339  private void insertIntoHistory(LocalMigrationResource local, int checksum, long exeMillis) throws SQLException {
340    MigrationMetaRow metaRow = createMetaRow(local, checksum, exeMillis);
341    metaRow.executeInsert(connection, insertSql);
342    addMigration(local.key(), metaRow);
343  }
344
345  /**
346   * Create the MigrationMetaRow for this migration.
347   */
348  private MigrationMetaRow createMetaRow(LocalMigrationResource migration, int checksum, long exeMillis) {
349
350    int nextId = 1;
351    if (lastMigration != null) {
352      nextId = lastMigration.getId() + 1;
353    }
354
355    String type = migration.getType();
356    String runVersion = migration.key();
357    String comment = migration.getComment();
358
359    return new MigrationMetaRow(nextId, type, runVersion, comment, checksum, envUserName, runOn, exeMillis);
360  }
361
362  /**
363   * Return true if the migration exists.
364   */
365  private boolean migrationExists(LocalMigrationResource priorVersion) {
366    return migrations.containsKey(priorVersion.key());
367  }
368
369  /**
370   * Apply the placeholder key/value replacement on the script.
371   */
372  private String convertScript(String script) {
373    return scriptTransform.transform(script);
374  }
375
376  /**
377   * Register the successfully executed migration (to allow dependant scripts to run).
378   */
379  private void addMigration(String key, MigrationMetaRow metaRow) {
380    lastMigration = metaRow;
381    if (metaRow.getVersion() == null) {
382      throw new IllegalStateException("No runVersion in db migration table row? " + metaRow);
383    }
384    migrations.put(key, metaRow);
385  }
386}