ENH: Add DB Migration runner (as alternative to running via FlywayDB etc) #636

This commit is contained in:
Robin Bygrave
2016-04-06 14:31:20 +12:00
27 changed files with 846 additions and 22 deletions
@@ -1,6 +1,10 @@
package com.avaje.ebean.config;
import java.io.IOException;
import java.net.URL;
import java.util.Enumeration;
/**
* Helper to find classes taking into account the context class loader.
*/
@@ -70,6 +74,10 @@ public class ClassLoadConfig {
}
}
public Enumeration<URL> getResources(String name) throws IOException {
return context.getResources(name);
}
/**
* Return true if the given class is present.
*/
@@ -122,6 +130,13 @@ public class ClassLoadConfig {
return (loader != null) ? loader: callerLoader;
}
Enumeration<URL> getResources(String name) throws IOException {
if (preferredLoader != null) {
return preferredLoader.getResources(name);
}
return contextLoader().getResources(name);
}
Class<?> forName(String name) throws ClassNotFoundException {
if (preferredLoader != null) {
@@ -9,6 +9,10 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
/**
@@ -522,4 +526,25 @@ public class DatabasePlatform {
return disallowBatchOnCascade;
}
/**
* Return true if the table exists.
*/
public boolean tableExists(Connection connection, String catalog, String schema, String table) throws SQLException {
DatabaseMetaData metaData = connection.getMetaData();
ResultSet tables = metaData.getTables(catalog, schema, table, null);
try {
return tables.next();
} finally {
close(tables);
}
}
protected void close(ResultSet resultSet) {
try {
resultSet.close();
} catch (SQLException e) {
logger.error("Error closing resultSet", e);
}
}
}
@@ -3,6 +3,7 @@ package com.avaje.ebean.dbmigration;
import com.avaje.ebean.config.ServerConfig;
import com.avaje.ebean.dbmigration.model.CurrentModel;
import com.avaje.ebeaninternal.api.SpiEbeanServer;
import com.avaje.ebeaninternal.util.JdbcClose;
import javax.persistence.PersistenceException;
import java.io.File;
@@ -13,6 +14,7 @@ import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.LineNumberReader;
import java.io.Reader;
import java.sql.Connection;
/**
* Controls the generation and execution of "Create All" and "Drop All" DDL scripts.
@@ -81,6 +83,21 @@ public class DdlGenerator {
}
}
/**
* Execute all the DDL statements in the script.
*/
public int runScript(boolean expectErrors, String content, String scriptName) {
DdlRunner runner = new DdlRunner(expectErrors, scriptName);
// get a connection without threadLocal
Connection connection = server.createTransaction().getConnection();
try {
return runner.runAll(content, connection);
} finally {
JdbcClose.close(connection);
}
}
protected void runDropSql() throws IOException {
if (!createOnly) {
if (dropAllContent == null) {
@@ -110,9 +127,8 @@ public class DdlGenerator {
if (sqlScript != null) {
InputStream is = getClassLoader().getResourceAsStream(sqlScript);
if (is != null) {
DdlRunner runner = new DdlRunner(false, sqlScript);
String content = readContent(new InputStreamReader(is));
runner.runAll(content, server);
runScript(false, content, sqlScript);
}
}
}
@@ -221,13 +237,4 @@ public class DdlGenerator {
}
}
/**
* Execute all the DDL statements in the script.
*/
public int runScript(boolean expectErrors, String content, String scriptName) {
DdlRunner runner = new DdlRunner(expectErrors, scriptName);
return runner.runAll(content, server);
}
}
@@ -1,7 +1,5 @@
package com.avaje.ebean.dbmigration;
import com.avaje.ebean.Transaction;
import com.avaje.ebeaninternal.api.SpiEbeanServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -37,29 +35,33 @@ public class DdlRunner {
/**
* Parse the content into sql statements and execute them in a transaction.
*/
public int runAll(String content, SpiEbeanServer server) {
public int runAll(String content, Connection connection) {
List<String> statements = ddlParser.parse(new StringReader(content));
return runStatements(statements, server);
return runStatements(statements, connection);
}
/**
* Execute all the statements in a single transaction.
*/
public int runStatements(List<String> statements, SpiEbeanServer server) {
public int runStatements(List<String> statements, Connection connection) {
Transaction t = server.createTransaction();
try {
int statementCount = runStatements(expectErrors, statements, t.getConnection());
t.commit();
int statementCount = runStatements(expectErrors, statements, connection);
connection.commit();
return statementCount;
} catch (Exception e) {
rollback(connection);
throw new PersistenceException("Error: " + e.getMessage(), e);
}
}
} finally {
t.end();
private void rollback(Connection connection) {
try {
connection.rollback();
} catch (SQLException e) {
logger.error("Error trying to rollback connection", e);
}
}
@@ -0,0 +1,72 @@
package com.avaje.ebean.dbmigration;
import com.avaje.ebean.EbeanServer;
import com.avaje.ebean.Transaction;
import com.avaje.ebean.config.DbMigrationConfig;
import com.avaje.ebean.config.ServerConfig;
import com.avaje.ebean.dbmigration.runner.LocalMigrationResource;
import com.avaje.ebean.dbmigration.runner.LocalMigrationResources;
import com.avaje.ebean.dbmigration.runner.MigrationTable;
import com.avaje.ebeaninternal.util.JdbcClose;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.util.List;
/**
*
*/
public class MigrationRunner {
private static final Logger logger = LoggerFactory.getLogger(MigrationRunner.class);
final EbeanServer server;
final ServerConfig config;
private final DbMigrationConfig migrationConfig;
public MigrationRunner(EbeanServer server) {
this.server = server;
this.config = server.getPluginApi().getServerConfig();
migrationConfig = this.config.getMigrationConfig();
}
public void run() {
LocalMigrationResources resources = new LocalMigrationResources(config);
if (!resources.readResources()) {
logger.debug("no migrations to check");
return;
}
Transaction transaction = server.createTransaction();
Connection connection = transaction.getConnection();
try {
MigrationTable table = new MigrationTable(server, migrationConfig, connection);
table.createIfNeeded();
LocalMigrationResource priorVersion = null;
List<LocalMigrationResource> localVersions = resources.getVersions();
for (int i = 0; i < localVersions.size(); i++) {
LocalMigrationResource localVersion = localVersions.get(i);
if (i > 0) {
priorVersion = localVersions.get(i-1);
}
if (!table.shouldRun(localVersion, priorVersion)) {
break;
}
}
table.commit();
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
JdbcClose.close(connection);
}
}
}
@@ -28,6 +28,13 @@ public class MigrationVersion implements Comparable<MigrationVersion> {
this.comment = comment;
}
/**
* Return the full version.
*/
public String getFull() {
return raw;
}
public String toString() {
return raw;
}
@@ -0,0 +1,32 @@
package com.avaje.ebean.dbmigration.runner;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.util.zip.CRC32;
/**
* Calculates the checksum for the given string content.
*/
public class Checksum {
/**
* Returns the checksum of this string.
*/
public static int calculate(String str) {
final CRC32 crc32 = new CRC32();
BufferedReader bufferedReader = new BufferedReader(new StringReader(str));
try {
String line;
while ((line = bufferedReader.readLine()) != null) {
crc32.update(line.getBytes("UTF-8"));
}
} catch (IOException e) {
throw new RuntimeException("Failed to calculate checksum", e);
}
return (int) crc32.getValue();
}
}
@@ -0,0 +1,55 @@
package com.avaje.ebean.dbmigration.runner;
import com.avaje.ebean.dbmigration.model.MigrationVersion;
import org.avaje.classpath.scanner.Resource;
/**
* A DB migration resource.
*/
public class LocalMigrationResource implements Comparable<LocalMigrationResource> {
private final MigrationVersion version;
private final String location;
private final Resource resource;
public LocalMigrationResource(MigrationVersion version, String location, Resource resource) {
this.version = version;
this.location = location;
this.resource = resource;
}
public String toString() {
return version.toString();
}
/**
* Default ordering by version.
*/
@Override
public int compareTo(LocalMigrationResource o) {
return version.compareTo(o.version);
}
/**
* Return the underlying migration version.
*/
public MigrationVersion getVersion() {
return version;
}
/**
* Return the resource location.
*/
public String getLocation() {
return location;
}
/**
* Return the content for the migration apply ddl script.
*/
public String getContent() {
return resource.loadAsString("UTF-8");
}
}
@@ -0,0 +1,85 @@
package com.avaje.ebean.dbmigration.runner;
import com.avaje.ebean.config.DbMigrationConfig;
import com.avaje.ebean.config.ServerConfig;
import com.avaje.ebean.dbmigration.model.MigrationVersion;
import org.avaje.classpath.scanner.Location;
import org.avaje.classpath.scanner.MatchResource;
import org.avaje.classpath.scanner.Resource;
import org.avaje.classpath.scanner.Scanner;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
*
*/
public class LocalMigrationResources {
private static final Logger logger = LoggerFactory.getLogger(LocalMigrationResources.class);
private final ServerConfig serverConfig;
private final DbMigrationConfig migrationConfig;
private final List<LocalMigrationResource> versions = new ArrayList<LocalMigrationResource>();
public LocalMigrationResources(ServerConfig serverConfig) {
this.serverConfig = serverConfig;
this.migrationConfig = serverConfig.getMigrationConfig();
}
public boolean readResources() {
String migrationPath = migrationConfig.getMigrationPath();
ClassLoader classLoader = serverConfig.getClassLoadConfig().getClassLoader();
Scanner scanner = new Scanner(classLoader);
List<Resource> resourceList = scanner.scanForResources(migrationPath, new Match(migrationConfig));
logger.debug("resources: {}", resourceList);
for (Resource resource : resourceList) {
String filename = resource.getFilename();
if (filename.endsWith(migrationConfig.getApplySuffix())) {
int pos = filename.lastIndexOf(migrationConfig.getApplySuffix());
String mainName = filename.substring(0, pos);
MigrationVersion migrationVersion = MigrationVersion.parse(mainName);
LocalMigrationResource res = new LocalMigrationResource(migrationVersion, resource.getLocation(), resource);
versions.add(res);
}
}
Collections.sort(versions);
return !versions.isEmpty();
}
public List<LocalMigrationResource> getVersions() {
return versions;
}
static class Match implements MatchResource {
final DbMigrationConfig migrationConfig;
Match(DbMigrationConfig migrationConfig) {
this.migrationConfig = migrationConfig;
}
@Override
public boolean isMatch(String name) {
return name.endsWith(migrationConfig.getApplySuffix())
|| name.endsWith(migrationConfig.getModelSuffix())
|| name.endsWith(migrationConfig.getDropSuffix())
|| name.endsWith(migrationConfig.getRollbackSuffix());
}
}
}
@@ -0,0 +1,27 @@
package com.avaje.ebean.dbmigration.runner;
import java.sql.Timestamp;
/**
*
*/
public class MigrationMetaRow implements Comparable<MigrationMetaRow> {
int id;
String status;
String runVersion;
String depVersion;
String comment;
int checksum;
Timestamp runOn;
String runBy;
@Override
public int compareTo(MigrationMetaRow o) {
return (id < o.id) ? -1 : ((id == o.id) ? 0 : 1);
}
//String sql = "insert into ebean_migration (id,status,run_version,dep_version,comment,checksum,run_on,run_by,run_ip) "+
// "values (?,?,?,?,?,?,?,?,?)";
}
@@ -0,0 +1,26 @@
package com.avaje.ebean.dbmigration.runner;
import com.avaje.ebean.dbmigration.DdlRunner;
import java.sql.Connection;
/**
* Created by rob on 5/02/16.
*/
public class MigrationScriptRunner {
final Connection connection;
public MigrationScriptRunner(Connection connection) {
this.connection = connection;
}
/**
* Execute all the DDL statements in the script.
*/
public int runScript(boolean expectErrors, String content, String scriptName) {
DdlRunner runner = new DdlRunner(expectErrors, scriptName);
return runner.runAll(content, connection);
}
}
@@ -0,0 +1,164 @@
package com.avaje.ebean.dbmigration.runner;
import com.avaje.ebean.EbeanServer;
import com.avaje.ebean.SqlQuery;
import com.avaje.ebean.SqlRow;
import com.avaje.ebean.SqlUpdate;
import com.avaje.ebean.config.DbMigrationConfig;
import com.avaje.ebean.config.ServerConfig;
import com.avaje.ebean.config.dbplatform.DatabasePlatform;
import com.avaje.ebean.plugin.SpiServer;
import com.avaje.ebeaninternal.server.transaction.ExternalJdbcTransaction;
import com.avaje.ebeaninternal.util.IOUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.net.URL;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.util.Enumeration;
import java.util.List;
/**
* Created by rob on 5/02/16.
*/
public class MigrationTable {
private static final Logger logger = LoggerFactory.getLogger(MigrationTable.class);
private final Connection connection;
private final EbeanServer server;
private final DatabasePlatform databasePlatform;
private final DbMigrationConfig migrationConfig;
private final String catalog;
private final String schema;
private final String table;
private final ServerConfig serverConfig;
private final String envUserName;
int currentSearchPosition;
List<SqlRow> metaRows;
public MigrationTable(EbeanServer server, DbMigrationConfig migrationConfig, Connection connection) {
this.connection = connection;
this.server = server;
SpiServer pluginApi = server.getPluginApi();
this.serverConfig = pluginApi.getServerConfig();
this.databasePlatform = pluginApi.getDatabasePlatform();
this.migrationConfig = migrationConfig;
this.catalog = null;
this.schema = null;
this.table = "ebean_migration";
this.envUserName = System.getProperty("user.name");
}
public void createIfNeeded() throws SQLException, IOException {
if (!tableExists(connection)) {
createTable(connection);
}
ExternalJdbcTransaction t = new ExternalJdbcTransaction(connection);
SqlQuery sqlQuery = server.createSqlQuery("select * from ebean_migration order by id for update");
metaRows = server.findList(sqlQuery, t);
}
private void createTable(Connection connection) throws IOException {
String script = getCreateTableScript();
MigrationScriptRunner run = new MigrationScriptRunner(connection);
run.runScript(false, script, "create migration tables");
}
private String getCreateTableScript() throws IOException {
Enumeration<URL> resources = serverConfig.getClassLoadConfig().getResources("migration-support/create.sql");
if (resources.hasMoreElements()) {
URL url = resources.nextElement();
return IOUtils.readUtf8(url.openStream());
}
return null;
}
public boolean tableExists(Connection connection) throws SQLException {
return databasePlatform.tableExists(connection, catalog, schema, table);
}
public boolean shouldRun(LocalMigrationResource localVersion, LocalMigrationResource priorVersion) {
// if prior != null check previous version installed
// if previous version not installed ... error - missing version (prior version)
// if (!priorVersionNotInstalled(priorVersion)) {
//
// }
//
// // if localVersion installed
// // check checksum and return ok, or re-installable?
// // else
// // install version and continue
//
// if (runPosition >= metaRows.size()) {
// logger.debug("No matching row");
// runMigration(runPosition, localVersion);
// return true;
// }
return false;
}
private void checkInstalled(LocalMigrationResource priorVersion) {
if (priorVersion != null) {
searchFor(priorVersion);
}
}
private void searchFor(LocalMigrationResource priorVersion) {
}
public void commit() throws SQLException {
connection.commit();
}
private void runMigration(int runPosition, LocalMigrationResource localVersion) {
logger.debug("run migration "+localVersion.getLocation());
String script = localVersion.getContent();
MigrationScriptRunner run = new MigrationScriptRunner(connection);
run.runScript(false, script, "run migration version: "+localVersion.getVersion());
String sql = "insert into ebean_migration (id,status,run_version,dep_version,comment,checksum,run_on,run_by,run_ip) "+
"values (?,?,?,?,?,?,?,?,?)";
SqlUpdate sqlUpdate = server.createSqlUpdate(sql);
sqlUpdate.setParameter(1, runPosition+1);
sqlUpdate.setParameter(2, "success");
sqlUpdate.setParameter(3, localVersion.getVersion().getFull());
sqlUpdate.setParameter(4, "na");
sqlUpdate.setParameter(5, "comm");
sqlUpdate.setParameter(6, 0);
sqlUpdate.setParameter(7, new Timestamp(System.currentTimeMillis()));
sqlUpdate.setParameter(8, envUserName);
sqlUpdate.setParameter(9, "userIp");
ExternalJdbcTransaction t = new ExternalJdbcTransaction(connection);
server.execute(sqlUpdate, t);
}
}
@@ -0,0 +1,7 @@
package com.avaje.ebean.dbmigration.runner;
/**
* Created by rob on 1/04/16.
*/
public class PlaceholderBuilder {
}
@@ -0,0 +1,7 @@
package com.avaje.ebean.dbmigration.runner;
/**
* Created by rob on 1/04/16.
*/
public class ScriptTransform {
}
@@ -14,6 +14,7 @@ import com.avaje.ebean.config.EncryptKeyManager;
import com.avaje.ebean.config.ServerConfig;
import com.avaje.ebean.config.dbplatform.DatabasePlatform;
import com.avaje.ebean.dbmigration.DdlGenerator;
import com.avaje.ebean.dbmigration.MigrationRunner;
import com.avaje.ebean.event.BeanPersistController;
import com.avaje.ebean.event.readaudit.ReadAuditLogger;
import com.avaje.ebean.event.readaudit.ReadAuditPrepare;
@@ -339,6 +340,9 @@ public final class DefaultServer implements SpiServer, SpiEbeanServer {
if (migrationConfig != null) {
migrationConfig.generateOnStart(this);
}
MigrationRunner migrationRunner = new MigrationRunner(this);
migrationRunner.run();
}
/**
@@ -0,0 +1,99 @@
package com.avaje.ebeaninternal.util;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.net.URLEncoder;
/**
* Utilities for encoding and decoding strings.
*/
public final class EncodeUtil {
private EncodeUtil() {
/* no instances */
}
/**
* URL-encodes the specified UTF-8 string.
*/
public static String urlEncode(String string) {
if (string == null) return null;
try {
return URLEncoder.encode(string, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("Support for UTF-8 is mandated by the Java spec", e);
}
}
/**
* URL-decodes the specified string as a UTF-8 string.
*/
public static String urlDecode(String string) {
if (string == null) return null;
try {
return URLDecoder.decode(string, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("Support for UTF-8 is mandated by the Java spec", e);
}
}
/**
* Returns the bytes corresponding to the specified ASCII string.
*/
public static byte[] asciiToBytes(String string) {
if (string == null) return null;
try {
return string.getBytes("US-ASCII");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("Support for US-ASCII is mandated by the Java spec", e);
}
}
/**
* Returns the ASCII string corresponding to the specified bytes.
*/
public static String bytesToAscii(byte[] data) {
try {
return new String(data, "US-ASCII");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("Support for US-ASCII is mandated by the Java spec", e);
}
}
/**
* Returns the bytes corresponding to the specified UTF-8 string.
*/
public static byte[] utf8ToBytes(String string) {
if (string == null) return null;
try {
return string.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("Support for UTF-8 is mandated by the Java spec", e);
}
}
/**
* Returns the UTF-8 string corresponding to the specified bytes.
*/
public static String bytesToUtf8(byte[] data) {
try {
return new String(data, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new IllegalStateException("Support for UTF-8 is mandated by the Java spec", e);
}
}
/**
* Returns the UTF-8 string corresponding to the specified bytes.
*/
public static String decodeBytes(byte[] data, String encoding) {
try {
return new String(data, encoding);
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Error decoding bytes with "+encoding, e);
}
}
}
@@ -0,0 +1,95 @@
package com.avaje.ebeaninternal.util;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Reader;
/**
* Created by rob on 5/02/16.
*/
public class IOUtils {
/**
* Reads the entire contents of the specified input stream and returns them
* as a byte array.
*/
public static byte[] read(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
pump(in, buffer);
return buffer.toByteArray();
}
/**
* Reads the entire contents of the specified input stream and returns them
* as an ASCII string.
*/
public static String readAscii(InputStream in) throws IOException {
return EncodeUtil.bytesToAscii(read(in));
}
/**
* Reads the entire contents of the specified input stream and returns them
* as UTF-8 string.
*/
public static String readUtf8(InputStream in) throws IOException {
return EncodeUtil.bytesToUtf8(read(in));
}
/**
* Reads the entire contents of the specified input stream and returns them
* as a string using the encoding supplied.
*/
public static String readEncoded(InputStream in, String encoding) throws IOException {
return EncodeUtil.decodeBytes(read(in), encoding);
}
public static String read(Reader reader) throws IOException {
StringBuilder sb = new StringBuilder();
try {
char[] buffer = new char[4096];
for (; ; ) {
int len = reader.read(buffer);
if (len < 0) {
break;
}
sb.append(buffer, 0, len);
}
} finally {
reader.close();
}
return sb.toString();
}
/**
* Reads data from the specified input stream and copies it to the specified
* output stream, until the input stream is at EOF. Both streams are then
* closed.
*
* @throws IOException if the input or output stream is <code>null</code>
*/
public static void pump(InputStream in, OutputStream out) throws IOException {
if (in == null) throw new IOException("Input stream is null");
if (out == null) throw new IOException("Output stream is null");
try {
try {
byte[] buffer = new byte[4096];
for (; ; ) {
int bytes = in.read(buffer);
if (bytes < 0) {
break;
}
out.write(buffer, 0, bytes);
}
} finally {
in.close();
}
} finally {
out.close();
}
}
}
@@ -0,0 +1,26 @@
package com.avaje.ebeaninternal.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.Connection;
import java.sql.SQLException;
/**
* Utility for closing raw Jdbc resources.
*/
public class JdbcClose {
private static final Logger logger = LoggerFactory.getLogger(JdbcClose.class);
/**
* Close the connection logging if an error occurs.
*/
public static void close(Connection connection) {
try {
connection.close();
} catch (SQLException e) {
logger.warn("Error closing connection", e);
}
}
}