package ameba.db.migration.resources;
import ameba.core.Application;
import ameba.db.DataSourceManager;
import ameba.db.migration.Migration;
import ameba.db.migration.MigrationFeature;
import ameba.db.migration.models.ScriptInfo;
import ameba.exception.AmebaException;
import ameba.i18n.Messages;
import ameba.util.IOUtils;
import ameba.util.Result;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.text.StrSubstitutor;
import org.flywaydb.core.Flyway;
import org.flywaydb.core.api.MigrationInfo;
import org.flywaydb.core.api.MigrationState;
import org.glassfish.hk2.api.ServiceLocator;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.ws.rs.*;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.List;
import java.util.Map;
/**
* <p>MigrationResource class.</p>
*
* @author icode
*/
@Path(MigrationResource.MIGRATION_BASE_URI)
@Singleton
public class MigrationResource {
/**
* Constant <code>MIGRATION_BASE_URI="/@db/migration/"</code>
*/
public static final String MIGRATION_BASE_URI = "/@db/migration/";
private static final String MIGRATION_HTML;
static {
try {
MIGRATION_HTML = IOUtils.readFromResource("ameba/db/migration/migration.html");
} catch (IOException e) {
throw new AmebaException(e);
}
}
@Inject
private ServiceLocator locator;
@Inject
private Application.Mode mode;
@Inject
private Application application;
private Map<String, MigrationFail> failMigrations;
/**
* <p>listInfo.</p>
*
* @return a {@link java.util.Map} object.
*/
@GET
public Map<String, MigrationInfo[]> listInfo() {
Map<String, MigrationInfo[]> infoMap = Maps.newLinkedHashMap();
for (String dbName : DataSourceManager.getDataSourceNames()) {
Flyway flyway = locator.getService(Flyway.class, dbName);
infoMap.put(dbName, flyway.info().all());
}
return infoMap;
}
/**
* <p>info.</p>
*
* @param dbName a {@link java.lang.String} object.
* @param revision a {@link java.lang.String} object.
* @return a {@link javax.ws.rs.core.Response} object.
*/
@GET
@Path("{dbName}/{revision}")
public Response info(@PathParam("dbName") String dbName, @PathParam("revision") String revision) {
Flyway flyway = locator.getService(Flyway.class, dbName);
if (flyway == null)
throw new NotFoundException();
Object entity = null;
switch (revision) {
case "current":
entity = flyway.info().current();
break;
case "pending":
entity = flyway.info().pending();
break;
case "applied":
entity = flyway.info().applied();
break;
case "first": {
MigrationInfo[] migrationInfos = flyway.info().all();
if (migrationInfos.length == 0)
throw new NotFoundException();
entity = migrationInfos[0];
break;
}
case "resolved":
entity = resolved(flyway.info().all());
break;
case "failed":
entity = failed(flyway.info().all());
break;
case "future":
entity = future(flyway.info().all());
break;
case "latest": {
MigrationInfo[] migrationInfos = flyway.info().all();
if (migrationInfos.length == 0)
throw new NotFoundException();
entity = migrationInfos[migrationInfos.length - 1];
break;
}
default:
for (MigrationInfo info : flyway.info().all()) {
if (revision.equalsIgnoreCase(info.getVersion().getVersion())) {
entity = info;
break;
}
}
}
if (entity == null)
throw new NotFoundException();
return Response.ok(entity).build();
}
/**
* <p>scripts.</p>
*
* @return a {@link java.util.Map} object.
*/
@GET
@Path("scripts")
public Map<String, List<ScriptInfo>> scripts() {
Map<String, List<ScriptInfo>> infoMap = Maps.newLinkedHashMap();
for (String dbName : DataSourceManager.getDataSourceNames()) {
infoMap.put(dbName, scripts(dbName));
}
return infoMap;
}
/**
* <p>scripts.</p>
*
* @param dbName a {@link java.lang.String} object.
* @return a {@link java.util.List} object.
*/
@GET
@Path("{dbName}/scripts")
public List<ScriptInfo> scripts(@PathParam("dbName") String dbName) {
Migration migration = locator.getService(Migration.class, dbName);
if (migration == null)
throw new NotFoundException();
return migration.allScript();
}
/**
* <p>script.</p>
*
* @param dbName a {@link java.lang.String} object.
* @param revision a {@link java.lang.String} object.
* @return a {@link ameba.db.migration.models.ScriptInfo} object.
*/
@GET
@Path("{dbName}/scripts/{revision}")
public ScriptInfo script(@PathParam("dbName") String dbName, @PathParam("revision") String revision) {
Migration migration = locator.getService(Migration.class, dbName);
if (migration == null)
throw new NotFoundException();
return migration.getScript(revision);
}
/////////////////////////////////////////
//////// Database Migration /////////
/////////////////////////////////////////
/**
* <p>migrateView.</p>
*
* @param uuid a {@link java.lang.String} object.
* @return a {@link javax.ws.rs.core.Response} object.
*/
@GET
@Path("{uuid}")// uuid or dbName
public Response migrateView(@PathParam("uuid") String uuid) {
try {
MigrationFeature.checkMigrationId(uuid);
} catch (NotFoundException e) {
Flyway flyway = locator.getService(Flyway.class, uuid);
if (flyway == null)
throw new NotFoundException();
return Response.ok(flyway.info().all()).build();
}
Map<String, Migration> migrations = getMigrations();
Map<String, String> model = buildPageModel(MigrationType.MIGRATE, uuid);
String desc = model.get("description")
.concat(" - <input name=\"description\" placeholder=\"" +
Messages.get("view.app.database.migrate.description.placeholder")
+ "\">");
model.put("description", desc);
StringBuilder tabs = new StringBuilder();
StringBuilder diffs = new StringBuilder();
StrSubstitutor sub = new StrSubstitutor(model);
int i = 0;
for (String dbName : migrations.keySet()) {
Migration migration = migrations.get(dbName);
ScriptInfo info = migration.generate();
Flyway flyway = locator.getService(Flyway.class, dbName);
boolean hasTable;
try (Connection connection = flyway.getDataSource().getConnection()) {
hasTable = connection.getMetaData().getTables(null, null, flyway.getTable(), null).next();
} catch (SQLException e) {
throw new AmebaException(e);
}
tabs.append("<li i=\"").append(i).append("\" class=\"db-name\">").append(dbName).append("</li>");
diffs.append("<div class=\"diff\"><h2>");
if (hasTable) {
diffs.append(Messages.get("view.app.database.migrate.subTitle"));
} else {
diffs.append(Messages.get("view.app.database.migrate.baseline.subTitle"));
}
diffs.append("</h2><pre>")
.append(info.getApplyDdl())
.append("</pre></div>");
i++;
}
model.put("dbNames", tabs.toString());
model.put("diffs", diffs.toString());
return Response.ok(sub.replace(MIGRATION_HTML))
.type(MediaType.TEXT_HTML_TYPE)
.build();
}
/**
* <p>migrate.</p>
*
* @param desc a {@link java.lang.String} object.
* @param uuid a {@link java.lang.String} object.
* @return a {@link ameba.util.Result} object.
*/
@POST
@Path("{uuid}")
public Result migrate(@FormParam("description") String desc,
@PathParam("uuid") String uuid) {
MigrationFeature.checkMigrationId(uuid);
String generatedDesc = (mode.isDev() ? "dev " : "") + "migrate";
if (StringUtils.isNotBlank(desc)) {
generatedDesc = desc;
}
Map<String, Migration> migrations = getMigrations();
for (String dbName : migrations.keySet()) {
Migration migration = migrations.get(dbName);
ScriptInfo info = migration.generate();
info.setDescription(generatedDesc);
Flyway flyway = locator.getService(Flyway.class, dbName);
flyway.setBaselineDescription(info.getDescription());
flyway.setBaselineVersionAsString(info.getRevision());
try {
flyway.migrate();
migration.persist();
migration.reset();
} catch (Throwable err) {
if (failMigrations == null) {
synchronized (this) {
if (failMigrations == null) {
failMigrations = Maps.newHashMap();
}
}
}
failMigrations.put(dbName, MigrationFail.create(flyway, err, migration));
}
}
if (failMigrations == null || failMigrations.isEmpty()) {
return Result.success();
} else {
return Result.failure();
}
}
/**
* <p>repairView.</p>
*
* @param uuid a {@link java.lang.String} object.
* @return a {@link java.lang.String} object.
*/
@GET
@Path("{uuid}/repair")
@Produces("text/html")
public String repairView(@PathParam("uuid") String uuid) {
MigrationFeature.checkMigrationId(uuid);
if (failMigrations.isEmpty()) {
throw new NotFoundException();
}
//REPAIR
Map<String, String> model = buildPageModel(MigrationType.REPAIR, uuid);
StrSubstitutor sub = new StrSubstitutor(model);
model.put("migrationUri", model.get("migrationUri") + "/repair");
StringBuilder tabs = new StringBuilder();
StringBuilder diffs = new StringBuilder();
int i = 0;
for (Map.Entry<String, MigrationFail> failEntry : failMigrations.entrySet()) {
String dbName = failEntry.getKey();
MigrationFail fail = failEntry.getValue();
Migration migration = fail.migration;
ScriptInfo info = migration.generate();
tabs.append("<li i=\"").append(i).append("\" class=\"db-name\">").append(dbName).append("</li>");
diffs.append("<div class=\"diff\"><h2>");
diffs.append(Messages.get("view.app.database.repair.subTitle", fail.throwable.getLocalizedMessage()));
diffs.append("</h2><pre>")
.append(info.getApplyDdl())
.append("</pre></div>");
i++;
}
model.put("dbNames", tabs.toString());
model.put("diffs", diffs.toString());
return sub.replace(MIGRATION_HTML);
}
/**
* <p>repair.</p>
*
* @param uuid a {@link java.lang.String} object.
* @return a {@link ameba.util.Result} object.
*/
@POST
@Path("{uuid}/repair")
public Result repair(@PathParam("uuid") String uuid) {
MigrationFeature.checkMigrationId(uuid);
if (failMigrations.isEmpty()) {
throw new NotFoundException();
}
for (String dbName : failMigrations.keySet()) {
MigrationFail fail = failMigrations.get(dbName);
fail.flyway.repair();
fail.migration.persist();
fail.migration.reset();
}
failMigrations.clear();
return Result.success();
}
/////////////////////////////////////////
//////// inner utils /////////
/////////////////////////////////////////
private Map<String, String> buildPageModel(MigrationType type, String uuid) {
Map<String, String> valuesMap = Maps.newHashMap();
String key = type.key();
valuesMap.put("pageTitle", Messages.get("view.app.database." + key + ".page.title"));
valuesMap.put("title", Messages.get("view.app.database." + key + ".title"));
valuesMap.put("migrationUri", MIGRATION_BASE_URI + uuid);
valuesMap.put("description", Messages.get("view.app.database." + key + ".description"));
valuesMap.put("applyButtonText", Messages.get("view.app.database." + key + ".apply.button"));
return valuesMap;
}
private Map<String, Migration> getMigrations() {
Map<String, Migration> migrations = Maps.newHashMap();
Map<String, Object> properties = application.getProperties();
DataSourceManager.getDataSourceNames()
.stream()
.filter(dbName -> !"false".equals(properties.get("db." + dbName + ".migration.enabled")))
.forEach(dbName -> {
Migration migration = locator.getService(Migration.class, dbName);
if (migration.hasChanged()) {
migrations.put(dbName, migration);
}
});
if (migrations.isEmpty()) {
throw new NotFoundException();
}
return migrations;
}
/**
* Retrieves the full set of infos about the migrations resolved on the classpath.
*
* @return The resolved migrations. An empty array if none.
*/
private List<MigrationInfo> resolved(MigrationInfo[] migrationInfos) {
if (migrationInfos.length == 0)
throw new NotFoundException();
List<MigrationInfo> resolvedMigrations = Lists.newArrayList();
for (MigrationInfo migrationInfo : migrationInfos) {
if (migrationInfo.getState().isResolved()) {
resolvedMigrations.add(migrationInfo);
}
}
return resolvedMigrations;
}
/**
* Retrieves the full set of infos about the migrations that failed.
*
* @return The failed migrations. An empty array if none.
*/
private List<MigrationInfo> failed(MigrationInfo[] migrationInfos) {
if (migrationInfos.length == 0)
throw new NotFoundException();
List<MigrationInfo> failedMigrations = Lists.newArrayList();
for (MigrationInfo migrationInfo : migrationInfos) {
if (migrationInfo.getState().isFailed()) {
failedMigrations.add(migrationInfo);
}
}
return failedMigrations;
}
/**
* Retrieves the full set of infos about future migrations applied to the DB.
*
* @return The future migrations. An empty array if none.
*/
private List<MigrationInfo> future(MigrationInfo[] migrationInfos) {
if (migrationInfos.length == 0)
throw new NotFoundException();
List<MigrationInfo> futureMigrations = Lists.newArrayList();
for (MigrationInfo migrationInfo : migrationInfos) {
if ((migrationInfo.getState() == MigrationState.FUTURE_SUCCESS)
|| (migrationInfo.getState() == MigrationState.FUTURE_FAILED)) {
futureMigrations.add(migrationInfo);
}
}
return futureMigrations;
}
/**
* <p>Getter for the field <code>failMigrations</code>.</p>
*
* @return a {@link java.util.Map} object.
*/
public Map<String, MigrationFail> getFailMigrations() {
return failMigrations;
}
private enum MigrationType {
MIGRATE, REPAIR;
public String key() {
return name().toLowerCase();
}
}
private static class MigrationFail {
private Flyway flyway;
private Throwable throwable;
private Migration migration;
static MigrationFail create(Flyway flyway, Throwable throwable, Migration migration) {
MigrationFail fail = new MigrationFail();
fail.flyway = flyway;
fail.throwable = throwable;
fail.migration = migration;
return fail;
}
}
}