001package io.ebean.dbmigration.runner;
002
003import io.ebean.dbmigration.MigrationConfig;
004import io.ebean.dbmigration.MigrationException;
005import io.ebean.dbmigration.util.IOUtils;
006import io.ebean.dbmigration.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;
023
024/**
025 * Manages the migration table.
026 */
027public class MigrationTable {
028
029  private static final Logger logger = LoggerFactory.getLogger(MigrationTable.class);
030
031  private final Connection connection;
032  private final boolean checkState;
033
034  private final String catalog;
035  private final String schema;
036  private final String table;
037  private final String sqlTable;
038  private final String envUserName;
039  private final String platformName;
040
041  private final Timestamp runOn = new Timestamp(System.currentTimeMillis());
042
043  private final ScriptTransform scriptTransform;
044
045  private final String insertSql;
046  private final String updateSql;
047  private final String selectSql;
048
049  private final LinkedHashMap<String, MigrationMetaRow> migrations;
050  private final boolean skipChecksum;
051
052  private MigrationMetaRow lastMigration;
053
054  private final List<LocalMigrationResource> checkMigrations = new ArrayList<>();
055
056  /**
057   * Construct with server, configuration and jdbc connection (DB admin user).
058   */
059  public MigrationTable(MigrationConfig config, Connection connection, boolean checkState) {
060
061    this.connection = connection;
062    this.checkState = checkState;
063    this.migrations = new LinkedHashMap<>();
064
065    this.catalog = null;
066    this.skipChecksum = config.isSkipChecksum();
067    this.schema = config.getDbSchema();
068    this.table = config.getMetaTable();
069    this.platformName = config.getPlatformName();
070    this.sqlTable = sqlTable();
071    this.selectSql = MigrationMetaRow.selectSql(sqlTable, platformName);
072    this.insertSql = MigrationMetaRow.insertSql(sqlTable);
073    this.updateSql = MigrationMetaRow.updateSql(sqlTable);
074    this.scriptTransform = createScriptTransform(config);
075    this.envUserName = System.getProperty("user.name");
076  }
077
078  /**
079   * Return the migrations that have been run.
080   */
081  public List<LocalMigrationResource> ran() {
082    return checkMigrations;
083  }
084
085  private String sqlTable() {
086    if (schema != null) {
087      return schema + "." + table;
088    } else {
089      return table;
090    }
091  }
092
093  private String sqlPrimaryKey() {
094    return "pk_" + table;
095  }
096
097  /**
098   * Return the number of migrations in the DB migration table.
099   */
100  public int size() {
101    return migrations.size();
102  }
103
104  /**
105   * Create the ScriptTransform for placeholder key/value replacement.
106   */
107  private ScriptTransform createScriptTransform(MigrationConfig config) {
108
109    Map<String, String> map = PlaceholderBuilder.build(config.getRunPlaceholders(), config.getRunPlaceholderMap());
110    return new ScriptTransform(map);
111  }
112
113  /**
114   * Create the table is it does not exist.
115   * <p>
116   * Also holds DB lock on migration table and loads existing migrations.
117   * </p>
118   */
119  public void createIfNeededAndLock() throws SQLException, IOException {
120
121    if (!tableExists(connection)) {
122      createTable(connection);
123    }
124
125    // load existing migrations, hold DB lock on migration table
126    PreparedStatement query = connection.prepareStatement(selectSql);
127    try {
128      ResultSet resultSet = query.executeQuery();
129      try {
130        while (resultSet.next()) {
131          MigrationMetaRow metaRow = new MigrationMetaRow(resultSet);
132          addMigration(metaRow.getVersion(), metaRow);
133        }
134      } finally {
135        JdbcClose.close(resultSet);
136      }
137    } finally {
138      JdbcClose.close(query);
139    }
140  }
141
142  private void createTable(Connection connection) throws IOException, SQLException {
143
144    String tableScript = createTableDdl();
145    MigrationScriptRunner run = new MigrationScriptRunner(connection);
146    run.runScript(false, tableScript, "create migration table");
147  }
148
149  /**
150   * Return the create table script.
151   */
152  String createTableDdl() throws IOException {
153    String script = ScriptTransform.replace("${table}", sqlTable, getCreateTableScript());
154    return ScriptTransform.replace("${pk_table}", sqlPrimaryKey(), script);
155  }
156
157  /**
158   * Return the create table script.
159   */
160  private String getCreateTableScript() throws IOException {
161    // supply a script to override the default table create script
162    String script = readResource("migration-support/create-table.sql");
163    if (script == null && platformName != null && !platformName.isEmpty()) {
164      // look for platform specific create table
165      script = readResource("migration-support/" + platformName + "-create-table.sql");
166    }
167    if (script == null) {
168      // no, just use the default script
169      script = readResource("migration-support/default-create-table.sql");
170    }
171    return script;
172  }
173
174  private String readResource(String location) throws IOException {
175
176    Enumeration<URL> resources = getClassLoader().getResources(location);
177    if (resources.hasMoreElements()) {
178      URL url = resources.nextElement();
179      return IOUtils.readUtf8(url.openStream());
180    }
181    return null;
182  }
183
184  private ClassLoader getClassLoader() {
185    return Thread.currentThread().getContextClassLoader();
186  }
187
188  /**
189   * Return true if the table exists.
190   */
191  private boolean tableExists(Connection connection) throws SQLException {
192
193    String migTable = table;
194
195    DatabaseMetaData metaData = connection.getMetaData();
196    if (metaData.storesUpperCaseIdentifiers()) {
197      migTable = migTable.toUpperCase();
198    }
199    String checkCatalog = (catalog != null) ? catalog : connection.getCatalog();
200    String checkSchema = (schema != null) ? schema : connection.getSchema();
201    ResultSet tables = metaData.getTables(checkCatalog, checkSchema, migTable, null);
202    try {
203      return tables.next();
204    } finally {
205      JdbcClose.close(tables);
206    }
207  }
208
209  /**
210   * Return true if the migration ran successfully and false if the migration failed.
211   */
212  public boolean shouldRun(LocalMigrationResource localVersion, LocalMigrationResource priorVersion) throws SQLException {
213
214    if (priorVersion != null && !localVersion.isRepeatable()) {
215      if (!migrationExists(priorVersion)) {
216        logger.error("Migration {} requires prior migration {} which has not been run", localVersion.getVersion(), priorVersion.getVersion());
217        return false;
218      }
219    }
220
221    MigrationMetaRow existing = migrations.get(localVersion.key());
222    return runMigration(localVersion, existing);
223  }
224
225  /**
226   * Run the migration script.
227   *
228   * @param local    The local migration resource
229   * @param existing The information for this migration existing in the table
230   * @return True if the migrations should continue
231   */
232  private boolean runMigration(LocalMigrationResource local, MigrationMetaRow existing) throws SQLException {
233
234    String script = convertScript(local.getContent());
235    int checksum = Checksum.calculate(script);
236
237    if (existing != null && skipMigration(checksum, local, existing)) {
238      return true;
239    }
240
241    executeMigration(local, script, checksum, existing);
242    return true;
243  }
244
245  /**
246   * Return true if the migration should be skipped.
247   */
248  boolean skipMigration(int checksum, LocalMigrationResource local, MigrationMetaRow existing) {
249
250    boolean matchChecksum = (existing.getChecksum() == checksum);
251    if (matchChecksum) {
252      logger.trace("... skip unchanged migration {}", local.getLocation());
253      return true;
254    } else if (local.isRepeatable() || skipChecksum) {
255      // re-run the migration
256      return false;
257    } else {
258      throw new MigrationException("Checksum mismatch on migration " + local.getLocation());
259    }
260  }
261
262  /**
263   * Run a migration script as new migration or update on existing repeatable migration.
264   */
265  private void executeMigration(LocalMigrationResource local, String script, int checksum, MigrationMetaRow existing) throws SQLException {
266
267    if (checkState) {
268      checkMigrations.add(local);
269      // simulate the migration being run such that following migrations also match
270      addMigration(local.key(), createMetaRow(local, checksum, 1));
271      return;
272    }
273
274    logger.debug("run migration {}", local.getLocation());
275
276    long start = System.currentTimeMillis();
277    MigrationScriptRunner run = new MigrationScriptRunner(connection);
278    run.runScript(false, script, "run migration version: " + local.getVersion());
279
280    long exeMillis = System.currentTimeMillis() - start;
281
282    if (existing != null) {
283      existing.rerun(checksum, exeMillis, envUserName, runOn);
284      existing.executeUpdate(connection, updateSql);
285
286    } else {
287      MigrationMetaRow metaRow = createMetaRow(local, checksum, exeMillis);
288      metaRow.executeInsert(connection, insertSql);
289      addMigration(local.key(), metaRow);
290    }
291  }
292
293  /**
294   * Create the MigrationMetaRow for this migration.
295   */
296  private MigrationMetaRow createMetaRow(LocalMigrationResource migration, int checksum, long exeMillis) {
297
298    int nextId = 1;
299    if (lastMigration != null) {
300      nextId = lastMigration.getId() + 1;
301    }
302
303    String type = migration.getType();
304    String runVersion = migration.key();
305    String comment = migration.getComment();
306
307    return new MigrationMetaRow(nextId, type, runVersion, comment, checksum, envUserName, runOn, exeMillis);
308  }
309
310  /**
311   * Return true if the migration exists.
312   */
313  private boolean migrationExists(LocalMigrationResource priorVersion) {
314    return migrations.containsKey(priorVersion.key());
315  }
316
317  /**
318   * Apply the placeholder key/value replacement on the script.
319   */
320  private String convertScript(String script) {
321    return scriptTransform.transform(script);
322  }
323
324  /**
325   * Register the successfully executed migration (to allow dependant scripts to run).
326   */
327  private void addMigration(String key, MigrationMetaRow metaRow) {
328    lastMigration = metaRow;
329    if (metaRow.getVersion() == null) {
330      throw new IllegalStateException("No runVersion in db migration table row? " + metaRow);
331    }
332    migrations.put(key, metaRow);
333  }
334}