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}