001package io.ebean.migration.runner; 002 003import io.ebean.ddlrunner.ScriptTransform; 004import io.ebean.migration.JdbcMigration; 005import io.ebean.migration.MigrationConfig; 006import io.ebean.migration.MigrationException; 007import io.ebean.migration.MigrationVersion; 008import io.ebean.migration.util.IOUtils; 009import org.slf4j.Logger; 010import org.slf4j.LoggerFactory; 011 012import java.io.IOException; 013import java.net.URL; 014import java.sql.Connection; 015import java.sql.DatabaseMetaData; 016import java.sql.ResultSet; 017import java.sql.SQLException; 018import java.sql.Timestamp; 019import java.util.ArrayList; 020import java.util.Enumeration; 021import java.util.LinkedHashMap; 022import java.util.List; 023import java.util.Set; 024 025import static io.ebean.migration.MigrationVersion.BOOTINIT_TYPE; 026import static io.ebean.migration.MigrationVersion.VERSION_TYPE; 027 028/** 029 * Manages the migration table. 030 */ 031public class MigrationTable { 032 033 private static final Logger logger = LoggerFactory.getLogger("io.ebean.DDL"); 034 035 private static final String INIT_VER_0 = "0"; 036 037 private final Connection connection; 038 private final boolean checkState; 039 private final MigrationPlatform platform; 040 private final MigrationScriptRunner scriptRunner; 041 private final String catalog; 042 private final String schema; 043 private final String table; 044 private final String sqlTable; 045 private final String envUserName; 046 private final String platformName; 047 048 private final Timestamp runOn = new Timestamp(System.currentTimeMillis()); 049 050 private final ScriptTransform scriptTransform; 051 052 private final String insertSql; 053 private final String updateSql; 054 private final String updateChecksumSql; 055 056 private final LinkedHashMap<String, MigrationMetaRow> migrations; 057 private final boolean skipChecksum; 058 059 private final Set<String> patchInsertVersions; 060 private final Set<String> patchResetChecksumVersions; 061 private final boolean allowErrorInRepeatable; 062 063 private final MigrationVersion minVersion; 064 private final String minVersionFailMessage; 065 066 private MigrationVersion currentVersion; 067 private MigrationMetaRow lastMigration; 068 private LocalMigrationResource priorVersion; 069 070 private final List<LocalMigrationResource> checkMigrations = new ArrayList<>(); 071 072 /** 073 * Version of a dbinit script. When set this means all migration version less than this are ignored. 074 */ 075 private MigrationVersion dbInitVersion; 076 077 /** 078 * Construct with server, configuration and jdbc connection (DB admin user). 079 */ 080 public MigrationTable(MigrationConfig config, Connection connection, boolean checkState, MigrationPlatform platform) { 081 this.platform = platform; 082 this.connection = connection; 083 this.scriptRunner = new MigrationScriptRunner(connection, platform); 084 this.checkState = checkState; 085 this.migrations = new LinkedHashMap<>(); 086 this.catalog = null; 087 this.allowErrorInRepeatable = config.isAllowErrorInRepeatable(); 088 this.patchResetChecksumVersions = config.getPatchResetChecksumOn(); 089 this.patchInsertVersions = config.getPatchInsertOn(); 090 this.minVersion = initMinVersion(config.getMinVersion()); 091 this.minVersionFailMessage = config.getMinVersionFailMessage(); 092 this.skipChecksum = config.isSkipChecksum(); 093 this.schema = config.getDbSchema(); 094 this.table = config.getMetaTable(); 095 this.platformName = config.getPlatformName(); 096 this.sqlTable = initSqlTable(); 097 this.insertSql = MigrationMetaRow.insertSql(sqlTable); 098 this.updateSql = MigrationMetaRow.updateSql(sqlTable); 099 this.updateChecksumSql = MigrationMetaRow.updateChecksumSql(sqlTable); 100 this.scriptTransform = createScriptTransform(config); 101 this.envUserName = System.getProperty("user.name"); 102 } 103 104 private MigrationVersion initMinVersion(String minVersion) { 105 return (minVersion == null || minVersion.isEmpty()) ? null : MigrationVersion.parse(minVersion); 106 } 107 108 private String initSqlTable() { 109 if (schema != null) { 110 return schema + "." + table; 111 } else { 112 return table; 113 } 114 } 115 116 private String sqlPrimaryKey() { 117 return "pk_" + table; 118 } 119 120 /** 121 * Return the number of migrations in the DB migration table. 122 */ 123 public int size() { 124 return migrations.size(); 125 } 126 127 /** 128 * Returns the versions that are already applied. 129 */ 130 public Set<String> getVersions() { 131 return migrations.keySet(); 132 } 133 134 /** 135 * Create the ScriptTransform for placeholder key/value replacement. 136 */ 137 private ScriptTransform createScriptTransform(MigrationConfig config) { 138 return ScriptTransform.build(config.getRunPlaceholders(), config.getRunPlaceholderMap()); 139 } 140 141 /** 142 * Create the table is it does not exist. 143 * <p> 144 * Also holds DB lock on migration table and loads existing migrations. 145 * </p> 146 */ 147 public void createIfNeededAndLock() throws SQLException, IOException { 148 if (!tableExists()) { 149 createTable(); 150 } 151 obtainLockWithWait(); 152 readExistingMigrations(); 153 } 154 155 /** 156 * Obtain lock with wait, note that other nodes can insert and commit 157 * into the migration table during the wait so this query result won't 158 * contain all the executed migrations in that case. 159 */ 160 private void obtainLockWithWait() throws SQLException { 161 platform.lockMigrationTable(sqlTable, connection); 162 } 163 164 /** 165 * Read the migration table with details on what migrations have run. 166 * This must execute after we have completed the wait for the lock on 167 * the migration table such that it reads any migrations that have 168 * executed during the wait for the lock. 169 */ 170 private void readExistingMigrations() throws SQLException { 171 for (MigrationMetaRow metaRow : platform.readExistingMigrations(sqlTable, connection)) { 172 addMigration(metaRow.getVersion(), metaRow); 173 } 174 } 175 176 private void createTable() throws IOException, SQLException { 177 scriptRunner.runScript(createTableDdl(), "create migration table"); 178 createInitMetaRow().executeInsert(connection, insertSql); 179 } 180 181 /** 182 * Return the create table script. 183 */ 184 String createTableDdl() throws IOException { 185 String script = ScriptTransform.replace("${table}", sqlTable, getCreateTableScript()); 186 return ScriptTransform.replace("${pk_table}", sqlPrimaryKey(), script); 187 } 188 189 /** 190 * Return the create table script. 191 */ 192 private String getCreateTableScript() throws IOException { 193 // supply a script to override the default table create script 194 String script = readResource("migration-support/create-table.sql"); 195 if (script == null && platformName != null && !platformName.isEmpty()) { 196 // look for platform specific create table 197 script = readResource("migration-support/" + platformName + "-create-table.sql"); 198 } 199 if (script == null) { 200 // no, just use the default script 201 script = readResource("migration-support/default-create-table.sql"); 202 } 203 return script; 204 } 205 206 private String readResource(String location) throws IOException { 207 Enumeration<URL> resources = getClassLoader().getResources(location); 208 if (resources.hasMoreElements()) { 209 URL url = resources.nextElement(); 210 return IOUtils.readUtf8(url); 211 } 212 return null; 213 } 214 215 private ClassLoader getClassLoader() { 216 return Thread.currentThread().getContextClassLoader(); 217 } 218 219 /** 220 * Return true if the table exists. 221 */ 222 private boolean tableExists() throws SQLException { 223 String migTable = table; 224 DatabaseMetaData metaData = connection.getMetaData(); 225 if (metaData.storesUpperCaseIdentifiers()) { 226 migTable = migTable.toUpperCase(); 227 } 228 String checkCatalog = (catalog != null) ? catalog : connection.getCatalog(); 229 String checkSchema = (schema != null) ? schema : connection.getSchema(); 230 try (ResultSet tables = metaData.getTables(checkCatalog, checkSchema, migTable, null)) { 231 return tables.next(); 232 } 233 } 234 235 /** 236 * Return true if the migration ran successfully and false if the migration failed. 237 */ 238 private boolean shouldRun(LocalMigrationResource localVersion, LocalMigrationResource prior) throws SQLException { 239 if (prior != null && !localVersion.isRepeatable()) { 240 if (!migrationExists(prior)) { 241 logger.error("Migration {} requires prior migration {} which has not been run", localVersion.getVersion(), prior.getVersion()); 242 return false; 243 } 244 } 245 246 MigrationMetaRow existing = migrations.get(localVersion.key()); 247 if (!runMigration(localVersion, existing)) { 248 return false; 249 } 250 251 // migration was run successfully ... 252 priorVersion = localVersion; 253 return true; 254 } 255 256 /** 257 * Run the migration script. 258 * 259 * @param local The local migration resource 260 * @param existing The information for this migration existing in the table 261 * @return True if the migrations should continue 262 */ 263 private boolean runMigration(LocalMigrationResource local, MigrationMetaRow existing) throws SQLException { 264 String script = null; 265 int checksum; 266 if (local instanceof LocalDdlMigrationResource) { 267 script = convertScript(local.getContent()); 268 checksum = Checksum.calculate(script); 269 } else { 270 checksum = ((LocalJdbcMigrationResource) local).getChecksum(); 271 } 272 273 if (existing == null && patchInsertMigration(local, checksum)) { 274 return true; 275 } 276 if (existing != null && skipMigration(checksum, local, existing)) { 277 return true; 278 } 279 executeMigration(local, script, checksum, existing); 280 return true; 281 } 282 283 /** 284 * Return true if we 'patch history' inserting a DB migration without running it. 285 */ 286 private boolean patchInsertMigration(LocalMigrationResource local, int checksum) throws SQLException { 287 if (patchInsertVersions != null && patchInsertVersions.contains(local.key())) { 288 logger.info("patch migration - insert into history {}", local.getLocation()); 289 if (!checkState) { 290 insertIntoHistory(local, checksum, 0); 291 } 292 return true; 293 } 294 return false; 295 } 296 297 /** 298 * Return true if the migration should be skipped. 299 */ 300 boolean skipMigration(int checksum, LocalMigrationResource local, MigrationMetaRow existing) throws SQLException { 301 boolean matchChecksum = (existing.getChecksum() == checksum); 302 if (matchChecksum) { 303 logger.trace("... skip unchanged migration {}", local.getLocation()); 304 return true; 305 306 } else if (patchResetChecksum(existing, checksum)) { 307 logger.info("patch migration - reset checksum on {}", local.getLocation()); 308 return true; 309 310 } else if (local.isRepeatable() || skipChecksum) { 311 // re-run the migration 312 return false; 313 } else { 314 throw new MigrationException("Checksum mismatch on migration " + local.getLocation()); 315 } 316 } 317 318 /** 319 * Return true if the checksum is reset on the existing migration. 320 */ 321 private boolean patchResetChecksum(MigrationMetaRow existing, int newChecksum) throws SQLException { 322 if (isResetOnVersion(existing.getVersion())) { 323 if (!checkState) { 324 existing.resetChecksum(newChecksum, connection, updateChecksumSql); 325 } 326 return true; 327 } else { 328 return false; 329 } 330 } 331 332 private boolean isResetOnVersion(String version) { 333 return patchResetChecksumVersions != null && patchResetChecksumVersions.contains(version); 334 } 335 336 /** 337 * Run a migration script as new migration or update on existing repeatable migration. 338 */ 339 private void executeMigration(LocalMigrationResource local, String script, int checksum, MigrationMetaRow existing) throws SQLException { 340 if (checkState) { 341 checkMigrations.add(local); 342 // simulate the migration being run such that following migrations also match 343 addMigration(local.key(), createMetaRow(local, checksum, 1)); 344 return; 345 } 346 347 logger.debug("run migration {}", local.getLocation()); 348 349 long start = System.currentTimeMillis(); 350 try { 351 if (local instanceof LocalDdlMigrationResource) { 352 scriptRunner.runScript(script, "run migration version: " + local.getVersion()); 353 } else { 354 JdbcMigration migration = ((LocalJdbcMigrationResource) local).getMigration(); 355 logger.info("Executing jdbc migration version: {} - {}", local.getVersion(), migration); 356 migration.migrate(connection); 357 } 358 long exeMillis = System.currentTimeMillis() - start; 359 360 if (existing != null) { 361 existing.rerun(checksum, exeMillis, envUserName, runOn); 362 existing.executeUpdate(connection, updateSql); 363 364 } else { 365 insertIntoHistory(local, checksum, exeMillis); 366 } 367 } catch (SQLException e) { 368 if (allowErrorInRepeatable && local.isRepeatableLast()) { 369 // log the exception and continue on repeatable migration 370 logger.error("Continue migration with error executing repeatable migration " + local.getVersion(), e); 371 } else { 372 throw e; 373 } 374 } 375 } 376 377 private void insertIntoHistory(LocalMigrationResource local, int checksum, long exeMillis) throws SQLException { 378 MigrationMetaRow metaRow = createMetaRow(local, checksum, exeMillis); 379 metaRow.executeInsert(connection, insertSql); 380 addMigration(local.key(), metaRow); 381 } 382 383 private MigrationMetaRow createInitMetaRow() { 384 return new MigrationMetaRow(0, "I", INIT_VER_0, "<init>", 0, envUserName, runOn, 0); 385 } 386 387 /** 388 * Create the MigrationMetaRow for this migration. 389 */ 390 private MigrationMetaRow createMetaRow(LocalMigrationResource migration, int checksum, long exeMillis) { 391 392 int nextId = 1; 393 if (lastMigration != null) { 394 nextId = lastMigration.getId() + 1; 395 } 396 397 String type = migration.getType(); 398 String runVersion = migration.key(); 399 String comment = migration.getComment(); 400 401 return new MigrationMetaRow(nextId, type, runVersion, comment, checksum, envUserName, runOn, exeMillis); 402 } 403 404 /** 405 * Return true if the migration exists. 406 */ 407 private boolean migrationExists(LocalMigrationResource priorVersion) { 408 return migrations.containsKey(priorVersion.key()); 409 } 410 411 /** 412 * Apply the placeholder key/value replacement on the script. 413 */ 414 private String convertScript(String script) { 415 return scriptTransform.transform(script); 416 } 417 418 /** 419 * Register the successfully executed migration (to allow dependant scripts to run). 420 */ 421 private void addMigration(String key, MigrationMetaRow metaRow) { 422 if (INIT_VER_0.equals(key)) { 423 // ignore the version 0 <init> row 424 return; 425 } 426 lastMigration = metaRow; 427 if (metaRow.getVersion() == null) { 428 throw new IllegalStateException("No runVersion in db migration table row? " + metaRow); 429 } 430 431 migrations.put(key, metaRow); 432 if (VERSION_TYPE.equals(metaRow.getType()) || BOOTINIT_TYPE.equals(metaRow.getType())) { 433 MigrationVersion rowVersion = MigrationVersion.parse(metaRow.getVersion()); 434 if (currentVersion == null || rowVersion.compareTo(currentVersion) > 0) { 435 currentVersion = rowVersion; 436 } 437 if (BOOTINIT_TYPE.equals(metaRow.getType())) { 438 dbInitVersion = rowVersion; 439 } 440 } 441 } 442 443 /** 444 * Return true if there are no migrations. 445 */ 446 public boolean isEmpty() { 447 return migrations.isEmpty(); 448 } 449 450 /** 451 * Run all the migrations in order as needed. 452 * 453 * @return the migrations that have been run (collected if checkstate is true). 454 */ 455 public List<LocalMigrationResource> runAll(List<LocalMigrationResource> localVersions) throws SQLException { 456 457 checkMinVersion(); 458 for (LocalMigrationResource localVersion : localVersions) { 459 if (!localVersion.isRepeatable() && dbInitVersion != null && dbInitVersion.compareTo(localVersion.getVersion()) >= 0) { 460 logger.debug("migration skipped by dbInitVersion {}", dbInitVersion); 461 } else if (!shouldRun(localVersion, priorVersion)) { 462 break; 463 } 464 } 465 return checkMigrations; 466 } 467 468 private void checkMinVersion() { 469 if (minVersion != null && currentVersion != null && currentVersion.compareTo(minVersion) < 0) { 470 StringBuilder sb = new StringBuilder(); 471 if (minVersionFailMessage != null && !minVersionFailMessage.isEmpty()) { 472 sb.append(minVersionFailMessage).append(' '); 473 } 474 sb.append("MigrationVersion mismatch: v").append(currentVersion).append(" < v").append(minVersion); 475 throw new MigrationException(sb.toString()); 476 } 477 } 478 479 /** 480 * Run using an init migration. 481 * 482 * @return the migrations that have been run (collected if checkstate is true). 483 */ 484 public List<LocalMigrationResource> runInit(LocalMigrationResource initVersion, List<LocalMigrationResource> localVersions) throws SQLException { 485 486 runRepeatableInit(localVersions); 487 488 initVersion.setInitType(); 489 if (!shouldRun(initVersion, null)) { 490 throw new IllegalStateException("Expected to run init migration but it didn't?"); 491 } 492 493 // run any migrations greater that the init migration 494 for (LocalMigrationResource localVersion : localVersions) { 495 if (localVersion.compareTo(initVersion) > 0 && !shouldRun(localVersion, priorVersion)) { 496 break; 497 } 498 } 499 return checkMigrations; 500 } 501 502 private void runRepeatableInit(List<LocalMigrationResource> localVersions) throws SQLException { 503 for (LocalMigrationResource localVersion : localVersions) { 504 if (!localVersion.isRepeatableInit() || !shouldRun(localVersion, priorVersion)) { 505 break; 506 } 507 } 508 } 509 510 /** 511 * Run non transactional statements (if any) after migration commit. 512 * <p> 513 * These run with auto commit true and run AFTER the migration commit and 514 * as such the migration isn't truely atomic - the migration can run and 515 * complete and the non-transactional statements fail. 516 */ 517 public void runNonTransactional() { 518 scriptRunner.runNonTransactional(); 519 } 520}