001package org.avaje.dbmigration.runner;
002
003import org.avaje.dbmigration.MigrationConfig;
004import org.avaje.dbmigration.util.IOUtils;
005import org.avaje.dbmigration.util.JdbcClose;
006import org.slf4j.Logger;
007import org.slf4j.LoggerFactory;
008
009import java.io.IOException;
010import java.net.URL;
011import java.sql.Connection;
012import java.sql.DatabaseMetaData;
013import java.sql.PreparedStatement;
014import java.sql.ResultSet;
015import java.sql.SQLException;
016import java.sql.Timestamp;
017import java.util.Enumeration;
018import java.util.LinkedHashMap;
019import java.util.Map;
020
021/**
022 * Manages the migration table.
023 */
024public class MigrationTable {
025
026  private static final Logger logger = LoggerFactory.getLogger(MigrationTable.class);
027
028  private final Connection connection;
029
030  private final String catalog;
031  private final String schema;
032  private final String table;
033  private final String envUserName;
034
035  private final Timestamp runOn = new Timestamp(System.currentTimeMillis());
036
037  private final ScriptTransform scriptTransform;
038
039  private final String insertSql;
040  private final String selectSql;
041
042  private final LinkedHashMap<String, MigrationMetaRow> migrations;
043
044  private MigrationMetaRow lastMigration;
045
046  /**
047   * Construct with server, configuration and jdbc connection (DB admin user).
048   */
049  public MigrationTable(MigrationConfig config, Connection connection) {
050
051    this.connection = connection;
052    this.migrations = new LinkedHashMap<String, MigrationMetaRow>();
053
054    this.catalog = null;
055    this.schema = null;
056    this.table = config.getMetaTable();
057    this.selectSql = MigrationMetaRow.selectSql(table);
058    this.insertSql = MigrationMetaRow.insertSql(table);
059    this.scriptTransform = createScriptTransform(config);
060    this.envUserName = System.getProperty("user.name");
061  }
062
063  /**
064   * Return the number of migrations in the DB migration table.
065   */
066  public int size() {
067    return migrations.size();
068  }
069
070  /**
071   * Create the ScriptTransform for placeholder key/value replacement.
072   */
073  private ScriptTransform createScriptTransform(MigrationConfig config) {
074
075    Map<String, String> map = PlaceholderBuilder.build(config.getRunPlaceholders(), config.getRunPlaceholderMap());
076    return new ScriptTransform(map);
077  }
078
079  /**
080   * Create the table is it does not exist.
081   */
082  public void createIfNeeded() throws SQLException, IOException {
083
084    if (!tableExists(connection)) {
085      createTable(connection);
086    }
087
088    PreparedStatement query = connection.prepareStatement(selectSql);
089    try {
090      ResultSet resultSet = query.executeQuery();
091      try {
092        while (resultSet.next()) {
093          MigrationMetaRow metaRow = new MigrationMetaRow(resultSet);
094          addMigration(metaRow.getVersion(), metaRow);
095        }
096      } finally {
097        JdbcClose.close(resultSet);
098      }
099    } finally {
100      JdbcClose.close(query);
101    }
102  }
103
104
105  private void createTable(Connection connection) throws IOException, SQLException {
106
107    String script = ScriptTransform.table(table, getCreateTableScript());
108
109    MigrationScriptRunner run = new MigrationScriptRunner(connection);
110    run.runScript(false, script, "create migration table");
111  }
112
113  /**
114   * Return the create table script.
115   */
116  private String getCreateTableScript() throws IOException {
117    // supply a script to override the default table create script
118    String script = readResource("migration-support/create-table.sql");
119    if (script == null) {
120      // no, just use the default script
121      script = readResource("migration-support/default-create-table.sql");
122    }
123    return script;
124  }
125
126  private String readResource(String location) throws IOException {
127
128    Enumeration<URL> resources = getClassLoader().getResources(location);
129    if (resources.hasMoreElements()) {
130      URL url = resources.nextElement();
131      return IOUtils.readUtf8(url.openStream());
132    }
133    return null;
134  }
135
136  private ClassLoader getClassLoader() {
137    return Thread.currentThread().getContextClassLoader();
138  }
139
140  /**
141   * Return true if the table exists.
142   */
143  private boolean tableExists(Connection connection) throws SQLException {
144
145    String migTable = table;
146
147    DatabaseMetaData metaData = connection.getMetaData();
148    if (metaData.storesUpperCaseIdentifiers()) {
149      migTable = migTable.toUpperCase();
150    }
151    ResultSet tables = metaData.getTables(catalog, schema, migTable, null);
152    try {
153      return tables.next();
154    } finally {
155      JdbcClose.close(tables);
156    }
157  }
158
159  /**
160   * Return true if the migration ran successfully and false if the migration failed.
161   */
162  public boolean shouldRun(LocalMigrationResource localVersion, LocalMigrationResource priorVersion) throws SQLException {
163
164    if (priorVersion != null && !localVersion.isRepeatable()) {
165      if (!migrationExists(priorVersion)) {
166        logger.error("Migration {} requires prior migration {} which has not been run", localVersion.getVersion(), priorVersion.getVersion());
167        return false;
168      }
169    }
170
171    MigrationMetaRow existing = migrations.get(localVersion.key());
172    return runMigration(localVersion, existing);
173  }
174
175  /**
176   * Run the migration script.
177   *
178   * @param local    The local migration resource
179   * @param existing The information for this migration existing in the table
180   *
181   * @return True if the migrations should continue
182   */
183  private boolean runMigration(LocalMigrationResource local, MigrationMetaRow existing) throws SQLException {
184
185    String script = convertScript(local.getContent());
186    int checksum = Checksum.calculate(script);
187
188    if (existing != null) {
189
190      boolean matchChecksum = (existing.getChecksum() == checksum);
191
192      if (!local.isRepeatable()) {
193        if (!matchChecksum) {
194          logger.error("Checksum mismatch on migration {}", local.getLocation());
195        }
196        return true;
197
198      } else if (matchChecksum) {
199        logger.trace("... skip unchanged repeatable migration {}", local.getLocation());
200        return true;
201      }
202    }
203
204    runMigration(local, script, checksum);
205    return true;
206  }
207
208  /**
209   * Run a migration script as new migration or update on existing repeatable migration.
210   */
211  private void runMigration(LocalMigrationResource local, String script, int checksum) throws SQLException {
212
213    logger.debug("run migration {}", local.getLocation());
214
215    long start = System.currentTimeMillis();
216    MigrationScriptRunner run = new MigrationScriptRunner(connection);
217    run.runScript(false, script, "run migration version: " + local.getVersion());
218
219    long exeMillis = System.currentTimeMillis() - start;
220
221    MigrationMetaRow metaRow = createMetaRow(local, checksum, exeMillis);
222    PreparedStatement statement = connection.prepareStatement(insertSql);
223    try {
224      metaRow.bindInsert(statement);
225      statement.executeUpdate();
226      addMigration(local.key(), metaRow);
227    } finally {
228      JdbcClose.close(statement);
229    }
230  }
231
232  /**
233   * Create the MigrationMetaRow for this migration.
234   */
235  private MigrationMetaRow createMetaRow(LocalMigrationResource migration, int checksum, long exeMillis) {
236
237    int nextId = 1;
238    if (lastMigration != null) {
239      nextId = lastMigration.getId() + 1;
240    }
241
242    String type = migration.getType();
243    String runVersion = migration.key();
244    String comment = migration.getComment();
245
246    return new MigrationMetaRow(nextId, type, runVersion, comment, checksum, envUserName, runOn, exeMillis);
247  }
248
249  /**
250   * Return true if the migration exists.
251   */
252  private boolean migrationExists(LocalMigrationResource priorVersion) {
253    return migrations.containsKey(priorVersion.key());
254  }
255
256  /**
257   * Apply the placeholder key/value replacement on the script.
258   */
259  private String convertScript(String script) {
260    return scriptTransform.transform(script);
261  }
262
263  /**
264   * Register the successfully executed migration (to allow dependant scripts to run).
265   */
266  private void addMigration(String key, MigrationMetaRow metaRow) {
267    lastMigration = metaRow;
268    if (metaRow.getVersion() == null) {
269      throw new IllegalStateException("No runVersion in db migration table row? " + metaRow);
270    }
271    migrations.put(key, metaRow);
272  }
273}