diff --git a/README.md b/README.md index c8cb636..7b8387a 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,32 @@ Then create a run a MigrationRunner. When running the database connection can ei runner.run(); ``` + +### Run from ebean with JDBC migrations + +Migration supports two modes how to run automatically on ebean server start: + +- Run as AutoRunner (controlled by `ebean.migration.run = true`) +- Run as Ebean plugin (controlled by `ebean.migration.plugin.run = true`) + +To run as plugin, you need the additional dependency to `ebean-migration-db`. + +In the AutoRunner mode, you only have a `MigrationContext` that provides access to the current connection. +When the plugin is used, you have a `MigrationContextDb` in your JDBC migrations and you can access the current transaction +and the ebean server. This is useful to make queries with the ebean server: +```java + public void migrate(MigrationContext context) throws SQLException { + Database db = ((MigrationContextDb) context).database(); + db.findDto(...) + } +``` + + +**Important**: +- do not use `DB.getDefault()` at this stage +- do not create new transactions (or committing existing one) +- be aware that the DB layout may not match to your entities (use `db.findDto` instead of `db.find`) + ## Notes: MigrationConfig migrationPath is the root path (classpath or filesystem) where the migration scripts are searched for. diff --git a/ebean-migration-db/pom.xml b/ebean-migration-db/pom.xml new file mode 100644 index 0000000..ebc061b --- /dev/null +++ b/ebean-migration-db/pom.xml @@ -0,0 +1,169 @@ + + + 4.0.0 + + org.avaje + java11-oss + 3.12 + + + + io.ebean + ebean-migration-db + 14.0.1-SNAPSHOT + + + scm:git:git@github.com:ebean-orm/ebean-migration.git + HEAD + + + + true + false + + + + + + io.ebean + ebean-migration + 14.0.1-SNAPSHOT + + + + io.ebean + ebean-api + 13.25.0 + provided + + + + + io.avaje + classpath-scanner + 7.1 + + + + + io.ebean + ebean + 13.25.0 + test + + + + com.h2database + h2 + 2.2.220 + test + + + + com.microsoft.sqlserver + mssql-jdbc + 9.4.1.jre8 + test + + + + mysql + mysql-connector-java + 8.0.28 + test + + + + org.postgresql + postgresql + 42.4.3 + test + + + + com.nuodb.jdbc + nuodb-jdbc + 22.0.0 + test + + + + com.ibm.db2 + jcc + 11.5.6.0 + test + + + + org.mariadb.jdbc + mariadb-java-client + 3.0.6 + test + + + + com.oracle.database.jdbc + + ojdbc8 + 19.12.0.0 + test + + + + + + + + + + + + org.avaje.composite + logback + 1.1 + test + + + + io.ebean + ebean-test-containers + 7.1 + test + + + + io.avaje + junit + 1.3 + test + + + + io.ebean + ebean-datasource + 8.0 + test + + + + + + + io.repaint.maven + tiles-maven-plugin + 2.33 + true + + + io.ebean.tile:enhancement:13.25.0 + + + + + + diff --git a/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationContextDb.java b/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationContextDb.java new file mode 100644 index 0000000..214becb --- /dev/null +++ b/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationContextDb.java @@ -0,0 +1,14 @@ +package io.ebean.migration.db; + +import io.ebean.Database; +import io.ebean.Transaction; +import io.ebean.migration.MigrationContext; + +/** + * @author Roland Praml, FOCONIS AG + */ +public interface MigrationContextDb extends MigrationContext { + public Transaction transaction(); + + public Database database(); +} diff --git a/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationPlugin.java b/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationPlugin.java new file mode 100644 index 0000000..63f817e --- /dev/null +++ b/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationPlugin.java @@ -0,0 +1,35 @@ +package io.ebean.migration.db; + +import io.ebean.migration.MigrationConfig; +import io.ebean.plugin.Plugin; +import io.ebean.plugin.SpiServer; + +/** + * @author Roland Praml, FOCONIS AG + */ +public class MigrationPlugin implements Plugin { + private MigrationConfig config = new MigrationConfig(); + private SpiServer server; + + @Override + public void configure(SpiServer server) { + config.setName(server.name()); + config.load(server.config().getProperties()); + this.server = server; + if (server.config().isRunMigration() && config.isPluginRun()) { + throw new UnsupportedOperationException("You cannot enable both 'migration.run' and 'migration.plugin.run'"); + } + } + + @Override + public void online(boolean online) { + if (online && config.isPluginRun()) { + new MigrationRunnerDb(config, server).run(); + } + } + + @Override + public void shutdown() { + + } +} diff --git a/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationRunnerDb.java b/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationRunnerDb.java new file mode 100644 index 0000000..2d5847c --- /dev/null +++ b/ebean-migration-db/src/main/java/io/ebean/migration/db/MigrationRunnerDb.java @@ -0,0 +1,44 @@ +package io.ebean.migration.db; + +import io.ebean.Database; +import io.ebean.Transaction; +import io.ebean.migration.MigrationConfig; +import io.ebean.migration.MigrationResource; +import io.ebean.migration.MigrationRunner; + +import java.util.List; + +/** + * Runs the default checkState and run method on a current ebean server. + * + * @author Roland Praml, FOCONIS AG + */ +public class MigrationRunnerDb extends MigrationRunner { + private final Database db; + + public MigrationRunnerDb(MigrationConfig migrationConfig, Database db) { + super(migrationConfig); + this.db = db; + } + + /** + * Return the migrations that would be applied if the migration is run. + */ + @Override + public List checkState() { + try (Transaction txn = db.beginTransaction()) { + return checkState(new TransactionBasedMigrationContext(migrationConfig, txn, db)); + } + } + + /** + * Run the migrations if there are any that need running. + */ + @Override + public void run() { + try (Transaction txn = db.beginTransaction()) { + run(new TransactionBasedMigrationContext(migrationConfig, txn, db)); + // No commit here! + } + } +} diff --git a/ebean-migration-db/src/main/java/io/ebean/migration/db/TransactionBasedMigrationContext.java b/ebean-migration-db/src/main/java/io/ebean/migration/db/TransactionBasedMigrationContext.java new file mode 100644 index 0000000..4c5dda9 --- /dev/null +++ b/ebean-migration-db/src/main/java/io/ebean/migration/db/TransactionBasedMigrationContext.java @@ -0,0 +1,66 @@ +package io.ebean.migration.db; + +import io.ebean.Database; +import io.ebean.Transaction; +import io.ebean.migration.MigrationConfig; +import io.ebean.migration.MigrationContext; + +import java.sql.Connection; +import java.sql.SQLException; + +/** + * A default implementation of the MigrationContext. + * + * @author Roland Praml, FOCONIS AG + */ +class TransactionBasedMigrationContext implements MigrationContextDb { + private final Transaction transaction; + private final String migrationPath; + private final String platform; + private final String basePlatform; + private final Database database; + + TransactionBasedMigrationContext(MigrationConfig config, Transaction transaction, Database database) { + this.transaction = transaction; + this.migrationPath = config.getMigrationPath(); + this.platform = config.getPlatform(); + this.basePlatform = config.getBasePlatform(); + this.database = database; + } + + @Override + public Connection connection() { + return transaction.connection(); + } + + @Override + public String migrationPath() { + return migrationPath; + } + + @Override + public String platform() { + return platform; + } + + @Override + public String basePlatform() { + return basePlatform; + } + + @Override + public void commit() throws SQLException { + // we must not use txn.commit here, as this closes the underlying connection, which is needed for logicalLock etc. + transaction.commitAndContinue(); + } + + @Override + public Transaction transaction() { + return transaction; + } + + @Override + public Database database() { + return database; + } +} diff --git a/ebean-migration-db/src/main/java/module-info.java b/ebean-migration-db/src/main/java/module-info.java new file mode 100644 index 0000000..15e0a6b --- /dev/null +++ b/ebean-migration-db/src/main/java/module-info.java @@ -0,0 +1,13 @@ +module io.ebean.migration.db { + + exports io.ebean.migration.db; + + requires transitive java.sql; + requires transitive io.avaje.applog; + requires transitive io.avaje.classpath.scanner; + requires transitive io.ebean.ddl.runner; + requires static io.ebean.api; + requires io.ebean.migration; + uses io.ebean.plugin.Plugin; + provides io.ebean.plugin.Plugin with io.ebean.migration.db.MigrationPlugin; +} diff --git a/ebean-migration-db/src/main/resources/META-INF/services/io.ebean.plugin.Plugin b/ebean-migration-db/src/main/resources/META-INF/services/io.ebean.plugin.Plugin new file mode 100644 index 0000000..df259f8 --- /dev/null +++ b/ebean-migration-db/src/main/resources/META-INF/services/io.ebean.plugin.Plugin @@ -0,0 +1 @@ +io.ebean.migration.db.MigrationPlugin diff --git a/ebean-migration-db/src/test/java/io/ebean/migration/it/EbeanMigrationTest.java b/ebean-migration-db/src/test/java/io/ebean/migration/it/EbeanMigrationTest.java new file mode 100644 index 0000000..2bd719f --- /dev/null +++ b/ebean-migration-db/src/test/java/io/ebean/migration/it/EbeanMigrationTest.java @@ -0,0 +1,76 @@ +package io.ebean.migration.it; + +import io.ebean.DB; +import io.ebean.Database; +import io.ebean.DatabaseFactory; +import io.ebean.config.DatabaseConfig; +import io.ebean.migration.MigrationConfig; +import io.ebean.migration.db.MigrationRunnerDb; +import org.junit.jupiter.api.Test; +import org.migration.model.M3; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Roland Praml, FOCONIS AG + */ +public class EbeanMigrationTest { + + /** + * Tests the direct invocation of the MigrantionRummerDb + */ + @Test + public void testEbeanServerJdbcMig() { + + // NOTE: This can be done by AutoMigration (or MigrationPlugin) later + MigrationConfig config = new MigrationConfig(); + Database db = DB.getDefault(); + try { + config.setName(db.name()); + config.load(db.pluginApi().config().getProperties()); + new MigrationRunnerDb(config, db).run(); + + + M3 m3 = DB.find(M3.class).where().idEq(1).findOne(); + assertThat(m3.getAcol()).isEqualTo("Migrate db PreCommit"); + } finally { + db.shutdown(true, false); + } + } + + @Test + public void testWithRunner() { + + // NOTE: This can be done by AutoMigration (or MigrationPlugin) later + DatabaseConfig dbCfg = new DatabaseConfig(); + dbCfg.setName("h2"); + dbCfg.loadFromProperties(); + dbCfg.setRunMigration(true); + Database db = DatabaseFactory.create(dbCfg); + try { + + M3 m3 = DB.find(M3.class).where().idEq(1).findOne(); + assertThat(m3.getAcol()).isEqualTo("Migrate raw"); + } finally { + db.shutdown(true, false); + } + + } + + @Test + public void testWithPlugin() { + DatabaseConfig dbCfg = new DatabaseConfig(); + dbCfg.setName("h2"); + dbCfg.loadFromProperties(); + dbCfg.getProperties().setProperty("ebean.h2.migration.plugin.run", "true"); + dbCfg.setDefaultServer(true); + Database db = DatabaseFactory.create(dbCfg); + try { + + M3 m3 = DB.find(M3.class).where().idEq(1).findOne(); + assertThat(m3.getAcol()).isEqualTo("Migrate db PreCommit"); + } finally { + db.shutdown(true, false); + } + } +} diff --git a/ebean-migration-db/src/test/java/io/ebean/migration/it/V1_2_1__test.java b/ebean-migration-db/src/test/java/io/ebean/migration/it/V1_2_1__test.java new file mode 100644 index 0000000..520316e --- /dev/null +++ b/ebean-migration-db/src/test/java/io/ebean/migration/it/V1_2_1__test.java @@ -0,0 +1,57 @@ +package io.ebean.migration.it; + +import io.ebean.DB; +import io.ebean.Database; +import io.ebean.Transaction; +import io.ebean.TxScope; +import io.ebean.migration.JdbcMigration; +import io.ebean.migration.MigrationContext; +import io.ebean.migration.db.MigrationContextDb; +import io.ebeaninternal.server.core.DefaultServer; +import io.ebeaninternal.server.transaction.ExternalJdbcTransaction; +import io.ebeaninternal.server.transaction.TransactionManager; +import org.migration.model.M3; + +import javax.persistence.PersistenceException; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Statement; +import static org.assertj.core.api.Assertions.assertThat; +/** + * Sample migration. + * + * @author Roland Praml, FOCONIS AG + */ +public class V1_2_1__test implements JdbcMigration { + + + @Override + public void migrate(MigrationContext context) throws SQLException { + if (context instanceof MigrationContextDb) { + // some asserts + Database db = ((MigrationContextDb) context).database(); // do not use DB.getDefault, as it is not yet registered! + + assertThat(((MigrationContextDb) context).transaction()) + .isNotNull() + .isSameAs(db.currentTransaction()); + + M3 m3 = db.find(M3.class).where().idEq(1).findOne(); + m3.setAcol("Migrate db"); + db.save(m3); + + } else { + try (PreparedStatement ps = context.connection().prepareStatement("update m3 set acol = ? where id = ?")) { + ps.setString(1, "Migrate raw"); + ps.setInt(2, 1); + ps.executeUpdate(); + } + } + } + + + @Override + public String toString() { + return "Dummy jdbc migration"; + } +} diff --git a/ebean-migration-db/src/test/java/org/migration/model/M3.java b/ebean-migration-db/src/test/java/org/migration/model/M3.java new file mode 100644 index 0000000..500e2ff --- /dev/null +++ b/ebean-migration-db/src/test/java/org/migration/model/M3.java @@ -0,0 +1,36 @@ +package org.migration.model; + +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.PreUpdate; + +/** + * @author Roland Praml, FOCONIS AG + */ +@Entity +public class M3 { + @Id + private int id; + private String acol; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getAcol() { + return acol; + } + + public void setAcol(String acol) { + this.acol = acol; + } + + @PreUpdate + public void addCommitMsg() { + acol += " PreCommit"; + } +} diff --git a/ebean-migration-db/src/test/resources/dbmig/1.1__initial.sql b/ebean-migration-db/src/test/resources/dbmig/1.1__initial.sql new file mode 100644 index 0000000..c627009 --- /dev/null +++ b/ebean-migration-db/src/test/resources/dbmig/1.1__initial.sql @@ -0,0 +1,5 @@ +create table m1 (id integer, acol varchar(20)); +-- Check with DB2: +-- call sysproc.admin_cmd('reorg table m1'); +create table m2 (id integer, acol varchar(20), bcol timestamp); + diff --git a/ebean-migration-db/src/test/resources/dbmig/1.2__add_m3.sql b/ebean-migration-db/src/test/resources/dbmig/1.2__add_m3.sql new file mode 100644 index 0000000..3518305 --- /dev/null +++ b/ebean-migration-db/src/test/resources/dbmig/1.2__add_m3.sql @@ -0,0 +1,5 @@ +create table m3 (id integer, acol varchar(20), bcol timestamp); + +alter table m1 add column addcol varchar(10); + +insert into m3 (id, acol) VALUES (1, 'text with ; sign'); -- plus some comment diff --git a/ebean-migration-db/src/test/resources/dbmig/I__hello.sql b/ebean-migration-db/src/test/resources/dbmig/I__hello.sql new file mode 100644 index 0000000..63a4934 --- /dev/null +++ b/ebean-migration-db/src/test/resources/dbmig/I__hello.sql @@ -0,0 +1 @@ +-- do nothing diff --git a/ebean-migration-db/src/test/resources/dbmig/R__m2_view.sql b/ebean-migration-db/src/test/resources/dbmig/R__m2_view.sql new file mode 100644 index 0000000..6a7c4df --- /dev/null +++ b/ebean-migration-db/src/test/resources/dbmig/R__m2_view.sql @@ -0,0 +1 @@ +create or replace view m2_vw as select id, acol from m2; diff --git a/ebean-migration-db/src/test/resources/ebean.properties b/ebean-migration-db/src/test/resources/ebean.properties new file mode 100644 index 0000000..1047822 --- /dev/null +++ b/ebean-migration-db/src/test/resources/ebean.properties @@ -0,0 +1,12 @@ +ebean.ddl.generate=false +ebean.ddl.run=false + +ebean.packages=org.migration.model + +datasource.default=h2 +datasource.h2.username=sa +datasource.h2.password= +datasource.h2.url=jdbc:h2:mem:testsMem;DB_CLOSE_ON_EXIT=FALSE;NON_KEYWORDS=KEY,VALUE + +ebean.h2.migration.migrationPath=dbmig +ebean.h2.migration.jdbcMigrations=io.ebean.migration.it.V1_2_1__test diff --git a/ebean-migration-db/src/test/resources/logback-test.xml b/ebean-migration-db/src/test/resources/logback-test.xml new file mode 100644 index 0000000..a227835 --- /dev/null +++ b/ebean-migration-db/src/test/resources/logback-test.xml @@ -0,0 +1,17 @@ + + + + TRACE + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/ebean-migration/pom.xml b/ebean-migration/pom.xml index 8d792dd..9ca2ec5 100644 --- a/ebean-migration/pom.xml +++ b/ebean-migration/pom.xml @@ -19,6 +19,7 @@ true + false diff --git a/ebean-migration/src/main/java/io/ebean/migration/ConfigurationAware.java b/ebean-migration/src/main/java/io/ebean/migration/ConfigurationAware.java deleted file mode 100644 index 13dc622..0000000 --- a/ebean-migration/src/main/java/io/ebean/migration/ConfigurationAware.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.ebean.migration; - -/** - * Marks a class as configuration aware (JdbcMigrations) Configuration aware - * classes get the migration configuration injected upon creation. The - * implementer is responsible for correctly storing the provided - * MigrationConfig (usually in a field). - * - * @author Roland Praml, FOCONIS AG - * - */ -public interface ConfigurationAware { - - /** - * Set the configuration being used. - */ - void setMigrationConfig(MigrationConfig config); -} diff --git a/ebean-migration/src/main/java/io/ebean/migration/JdbcMigration.java b/ebean-migration/src/main/java/io/ebean/migration/JdbcMigration.java index 4e07bc3..99bf021 100644 --- a/ebean-migration/src/main/java/io/ebean/migration/JdbcMigration.java +++ b/ebean-migration/src/main/java/io/ebean/migration/JdbcMigration.java @@ -1,16 +1,26 @@ package io.ebean.migration; import java.sql.Connection; +import java.sql.SQLException; /** * Interface to be implemented by Jdbc Java Migrations. By default the migration * version and description will be extracted from the class name. The checksum of this migration - * (for validation) will also be null, unless the migration also implements the - * MigrationChecksumProvider, in which case it can be returned programmatically. + * will be 0 by default *

- * When the JdbcMigration implements ConfigurationAware, the master - * {@link MigrationConfig} is automatically injected upon creation, which is - * useful for getting placeholder and schema information. + * Note: Instances of JdbcMigration should be stateless, as the migrate method may + * run multiple times in multi-tenant setups. + *

+ * There are several ways, how the JdbcMigrations are found. + *

* * @author Roland Praml, FOCONIS AG */ @@ -18,11 +28,43 @@ public interface JdbcMigration extends MigrationChecksumProvider { /** * Execute the migration using the connection. + *

+ * Note: This API has changed with ebean-migration 13.12, as the initialization has changed. + * See https://github.com/ebean-orm/ebean-migration/issues/90 for migration advice. */ - void migrate(Connection connection); + void migrate(MigrationContext context) throws SQLException; @Override default int getChecksum() { return 0; } + + /** + * Returns the name of the JdbcMigration. Note, the name is used to determine the version and comment, + * that is written to the migration table, so the returned value must be a parseable {@link MigrationVersion} + * (example: V1_2_1__comment) + *

+ * By default, the simple classname will be returned, so the file name can be used. + */ + default String getName() { + return getClass().getSimpleName(); + } + + /** + * Returns the version of the JdbcMigration. By default, it is parsed from {@link #getName()} + */ + default MigrationVersion getVersion() { + return MigrationVersion.parse(getName()); + } + + /** + * Determines, if this migration can be used for that migrationContext. + * Here, platform checks or other things can be implemented. + * You should not write to database at this stage. + *

+ * By default, true is returned. + */ + default boolean matches(MigrationContext context) { + return true; + } } diff --git a/ebean-migration/src/main/java/io/ebean/migration/JdbcMigrationFactory.java b/ebean-migration/src/main/java/io/ebean/migration/JdbcMigrationFactory.java deleted file mode 100644 index a5eabe3..0000000 --- a/ebean-migration/src/main/java/io/ebean/migration/JdbcMigrationFactory.java +++ /dev/null @@ -1,14 +0,0 @@ -package io.ebean.migration; - -/** - * Factory to create and initialise a JdbcMigration. - * - * @author Roland Praml, FOCONIS AG - */ -public interface JdbcMigrationFactory { - - /** - * Create a JDBC based migration given the class name. - */ - JdbcMigration createInstance(String className); -} diff --git a/ebean-migration/src/main/java/io/ebean/migration/MigrationConfig.java b/ebean-migration/src/main/java/io/ebean/migration/MigrationConfig.java index d33696f..a091448 100644 --- a/ebean-migration/src/main/java/io/ebean/migration/MigrationConfig.java +++ b/ebean-migration/src/main/java/io/ebean/migration/MigrationConfig.java @@ -3,9 +3,13 @@ import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; +import java.util.ArrayList; import java.util.HashSet; +import java.util.Iterator; +import java.util.List; import java.util.Map; import java.util.Properties; +import java.util.ServiceLoader; import java.util.Set; /** @@ -32,7 +36,17 @@ public class MigrationConfig { private boolean setCurrentSchema = true; private boolean allowErrorInRepeatable; - private JdbcMigrationFactory jdbcMigrationFactory = new DefaultMigrationFactory(); + /** + * Run migration automatically when ebean starts (Requires MigrationPlugin from ebean-migration-db) + */ + private boolean pluginRun; + + /** + * Holds a Collection/Iterable of JdbcMigrations. All migrations (JDBC and SQL) are + * extecuted in the order defined by their version numbers. + * By default, JdbcMigrations are loaded via ServiceLoader. + */ + private Iterable jdbcMigrations = new DefaultJdbcMigrations(); /** * Versions that we want to insert into migration history without actually running. @@ -416,17 +430,45 @@ public void setClassLoader(ClassLoader classLoader) { } /** - * Return the jdbcMigrationFactory. + * Return the jdbcMigrations. */ - public JdbcMigrationFactory getJdbcMigrationFactory() { - return jdbcMigrationFactory; + public Iterable getJdbcMigrations() { + return jdbcMigrations; } /** - * Set the jdbcMigrationFactory. + * Set the jdbcMigrations. If not set, the ServiceLoader is used. + * JdbcMigrations can be either defined with the property jdbcMigrations + * to a fully qualified class name implementing Iterable<JdbcMigration> or by + * specifying a comma separated list of {@link JdbcMigration}s in the jdbcMigrations property. + *

+ * Note: If you plan to run migrations in multi-tenant env in multiple threads, the provided factory + * must be thread safe! */ - public void setJdbcMigrationFactory(JdbcMigrationFactory jdbcMigrationFactory) { - this.jdbcMigrationFactory = jdbcMigrationFactory; + public void setJdbcMigrations(Iterable jdbcMigrationFactory) { + this.jdbcMigrations = jdbcMigrationFactory; + } + + /** + * Helper method to set migrations with the jdbcMigrations property. + * You can either pass ONE MigrationCollection or a list of JdbcMigrations. + */ + @SuppressWarnings("unchecked") + public void setJdbcMigrations(String... classNames) { + if (classNames.length == 1) { + Object candidate = newInstance(classNames[0].trim()); + if (candidate instanceof JdbcMigration) { + setJdbcMigrations(List.of((JdbcMigration) candidate)); + } else { + setJdbcMigrations((Iterable) candidate); + } + } else { + List migrations = new ArrayList<>(classNames.length); + for (String className : classNames) { + migrations.add(newInstance(className.trim())); + } + setJdbcMigrations(migrations); + } } /** @@ -457,6 +499,20 @@ public void setMinVersionFailMessage(String minVersionFailMessage) { this.minVersionFailMessage = minVersionFailMessage; } + /** + * Sets, if migration should automatically run when ebean starts. + */ + public void setPluginRun(boolean pluginRun) { + this.pluginRun = pluginRun; + } + + /** + * run migration on ebean start + */ + public boolean isPluginRun() { + return pluginRun; + } + /** * Load configuration from standard properties. */ @@ -483,6 +539,12 @@ public void load(Properties props) { runPlaceholders = property("placeholders", runPlaceholders); minVersion = property("minVersion", minVersion); minVersionFailMessage = property("minVersionFailMessage", minVersionFailMessage); + pluginRun = property("plugin.run", pluginRun); + + String jdbcMigrations = property("jdbcMigrations"); + if (jdbcMigrations != null) { + setJdbcMigrations(jdbcMigrations.split(",")); + } String patchInsertOn = property("patchInsertOn"); if (patchInsertOn != null) { @@ -498,6 +560,16 @@ public void load(Properties props) { } } + @SuppressWarnings("unchecked") + public T newInstance(String className) { + try { + Class cls = Class.forName(className, true, getClassLoader()); + return (T) cls.getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new IllegalArgumentException("Error constructing " + className, e); + } + } + private boolean property(String key, boolean value) { String val = property(key); return val != null ? Boolean.parseBoolean(val) : value; @@ -611,25 +683,14 @@ public void setFastMode(boolean fastMode) { } /** - * Default factory. Uses the migration's class loader and injects the config if necessary. - * - * @author Roland Praml, FOCONIS AG + * Default implementation for service-loader. Note: As ServiceLoader is not thread safe, + * it is better to return a new iterator each time. */ - public class DefaultMigrationFactory implements JdbcMigrationFactory { + private class DefaultJdbcMigrations implements Iterable { @Override - public JdbcMigration createInstance(String className) { - try { - Class clazz = Class.forName(className, true, MigrationConfig.this.getClassLoader()); - JdbcMigration migration = (JdbcMigration) clazz.getDeclaredConstructor().newInstance(); - if (migration instanceof ConfigurationAware) { - ((ConfigurationAware) migration).setMigrationConfig(MigrationConfig.this); - } - return migration; - } catch (Exception e) { - throw new IllegalArgumentException(className + " is not a valid JdbcMigration", e); - } + public Iterator iterator() { + return ServiceLoader.load(JdbcMigration.class, getClassLoader()).iterator(); } } - } diff --git a/ebean-migration/src/main/java/io/ebean/migration/MigrationContext.java b/ebean-migration/src/main/java/io/ebean/migration/MigrationContext.java index a2417ad..8422f34 100644 --- a/ebean-migration/src/main/java/io/ebean/migration/MigrationContext.java +++ b/ebean-migration/src/main/java/io/ebean/migration/MigrationContext.java @@ -1,6 +1,7 @@ package io.ebean.migration; import java.sql.Connection; +import java.sql.SQLException; /** * The current context while a migration runs. @@ -38,4 +39,10 @@ public interface MigrationContext { */ String basePlatform(); + /** + * Indicates that all migrations are done and the underlying connection or transaction should perform a commit. + *

+ * NOTE: + */ + void commit() throws SQLException; } diff --git a/ebean-migration/src/main/java/io/ebean/migration/runner/DefaultMigrationContext.java b/ebean-migration/src/main/java/io/ebean/migration/runner/DefaultMigrationContext.java index 96bc4c0..4df93c2 100644 --- a/ebean-migration/src/main/java/io/ebean/migration/runner/DefaultMigrationContext.java +++ b/ebean-migration/src/main/java/io/ebean/migration/runner/DefaultMigrationContext.java @@ -4,19 +4,20 @@ import io.ebean.migration.MigrationContext; import java.sql.Connection; +import java.sql.SQLException; /** * A default implementation of the MigrationContext. * * @author Roland Praml, FOCONIS AG */ -public class DefaultMigrationContext implements MigrationContext { +class DefaultMigrationContext implements MigrationContext { private final Connection connection; private final String migrationPath; private final String platform; private final String basePlatform; - public DefaultMigrationContext(MigrationConfig config, Connection connection) { + DefaultMigrationContext(MigrationConfig config, Connection connection) { this.connection = connection; this.migrationPath = config.getMigrationPath(); this.platform = config.getPlatform(); @@ -42,4 +43,9 @@ public String platform() { public String basePlatform() { return basePlatform; } + + @Override + public void commit() throws SQLException { + connection.commit(); + } } diff --git a/ebean-migration/src/main/java/io/ebean/migration/runner/LocalMigrationResources.java b/ebean-migration/src/main/java/io/ebean/migration/runner/LocalMigrationResources.java index cd605bb..eeb7c15 100644 --- a/ebean-migration/src/main/java/io/ebean/migration/runner/LocalMigrationResources.java +++ b/ebean-migration/src/main/java/io/ebean/migration/runner/LocalMigrationResources.java @@ -4,14 +4,17 @@ import io.avaje.classpath.scanner.core.Scanner; import io.ebean.migration.JdbcMigration; import io.ebean.migration.MigrationConfig; +import io.ebean.migration.MigrationContext; import io.ebean.migration.MigrationVersion; -import java.io.*; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.LineNumberReader; +import java.io.UncheckedIOException; import java.net.URL; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import java.util.function.Predicate; import static java.lang.System.Logger.Level.DEBUG; @@ -25,7 +28,7 @@ final class LocalMigrationResources { private final List versions = new ArrayList<>(); private final MigrationConfig migrationConfig; private final ClassLoader classLoader; - private final boolean searchForJdbcMigrations; + private final Iterable jdbcMigrations; /** * Construct with configuration options. @@ -33,53 +36,78 @@ final class LocalMigrationResources { LocalMigrationResources(MigrationConfig migrationConfig) { this.migrationConfig = migrationConfig; this.classLoader = migrationConfig.getClassLoader(); - this.searchForJdbcMigrations = migrationConfig.getJdbcMigrationFactory() != null; + this.jdbcMigrations = migrationConfig.getJdbcMigrations(); } /** * Read the init migration resources (usually only 1) returning true if there are versions. */ boolean readInitResources() { - return readResourcesForPath(migrationConfig.getMigrationInitPath()); + readResourcesForPath(migrationConfig.getMigrationInitPath()); + Collections.sort(versions); + return !versions.isEmpty(); } /** - * Read all the migration resources (SQL scripts) returning true if there are versions. + * Read all the migration resources (SQL scripts and JDBC migrations) returning true if there are versions. */ - boolean readResources() { + boolean readResources(MigrationContext context) { if (readFromIndex()) { // automatically enable earlyChecksumMode when using index file with pre-computed checksums migrationConfig.setEarlyChecksumMode(true); - return true; + } else { + readResourcesForPath(migrationConfig.getMigrationPath()); } - return readResourcesForPath(migrationConfig.getMigrationPath()); + // after we read the SQL migrations from index or classpath scan, we + // read jdbcMigrations and sort them. + readJdbcMigrations(context); + Collections.sort(versions); + return !versions.isEmpty(); } + /** + * Returns true, if an index file was found. Although, if file was empty, so we do not fall back + * to classpath scan! + */ private boolean readFromIndex() { final var base = "/" + migrationConfig.getMigrationPath() + "/"; final var basePlatform = migrationConfig.getBasePlatform(); final var indexName = "idx_" + basePlatform + ".migrations"; URL idx = resource(base + indexName); if (idx != null) { - return loadFromIndexFile(idx, base); + loadFromIndexFile(idx, base); + return true; } idx = resource(base + basePlatform + '/' + indexName); if (idx != null) { - return loadFromIndexFile(idx, base + basePlatform + '/'); + loadFromIndexFile(idx, base + basePlatform + '/'); + return true; } final var platform = migrationConfig.getPlatform(); idx = resource(base + platform + indexName); if (idx != null) { - return loadFromIndexFile(idx, base + platform + '/'); + loadFromIndexFile(idx, base + platform + '/'); + return true; } return false; } + private void readJdbcMigrations(MigrationContext context) { + if (jdbcMigrations != null) { + for (JdbcMigration jdbcMigration : jdbcMigrations) { + if (jdbcMigration.matches(context)) { + versions.add(new LocalJdbcMigrationResource(jdbcMigration.getVersion(), jdbcMigration.getName(), jdbcMigration)); + } + } + } + } + private URL resource(String base) { return LocalMigrationResources.class.getResource(base); } - private boolean loadFromIndexFile(URL idx, String base) { + private void loadFromIndexFile(URL idx, String base) { + log.log(DEBUG, "Loading index from {0}", idx); try (var reader = new LineNumberReader(new InputStreamReader(idx.openStream()))) { String line; while ((line = reader.readLine()) != null) { @@ -95,93 +123,63 @@ private boolean loadFromIndexFile(URL idx, String base) { } } } - - return !versions.isEmpty(); - } catch (IOException e) { throw new UncheckedIOException("Error reading idx file", e); } } - private boolean readResourcesForPath(String path) { + private void readResourcesForPath(String path) { // try to load from base platform first final String basePlatform = migrationConfig.getBasePlatform(); if (basePlatform != null && loadedFrom(path, basePlatform)) { - return true; + return; } // try to load from specific platform final String platform = migrationConfig.getPlatform(); if (platform != null && loadedFrom(path, platform)) { - return true; + return; } - addResources(scanForBoth(path)); - Collections.sort(versions); - return !versions.isEmpty(); + addResources(scanForMigrations(path)); } /** * Return true if migrations were loaded from platform specific location. */ private boolean loadedFrom(String path, String platform) { - addResources(scanForBoth(path + "/" + platform)); + addResources(scanForMigrations(path + "/" + platform)); if (versions.isEmpty()) { return false; } log.log(DEBUG, "platform migrations for {0}", platform); - if (searchForJdbcMigrations) { - addResources(scanForJdbcOnly(path)); - } - Collections.sort(versions); return true; } /** - * Scan only for JDBC migrations. + * Scan for SQL migrations. */ - private List scanForJdbcOnly(String path) { - return new Scanner(classLoader).scanForResources(path, new JdbcOnly()); + private List scanForMigrations(String path) { + return new Scanner(classLoader).scanForResources(path, name -> name.endsWith(".sql")); } /** - * Scan for both SQL and JDBC migrations. + * adds the script migrations found from classpath scan. */ - private List scanForBoth(String path) { - return new Scanner(classLoader).scanForResources(path, new Match(searchForJdbcMigrations)); - } - private void addResources(List resourceList) { if (!resourceList.isEmpty()) { log.log(DEBUG, "resources: {0}", resourceList); } for (Resource resource : resourceList) { String filename = resource.name(); - if (filename.endsWith(".sql")) { - versions.add(createScriptMigration(resource, filename)); - } else if (searchForJdbcMigrations && filename.endsWith(".class")) { - versions.add(createJdbcMigration(resource, filename)); - } + assert filename.endsWith(".sql"); + String mainName = filename.substring(0, filename.length() - 4); + versions.add(createScriptMigration(resource, mainName)); } } - /** - * Return a programmatic JDBC migration. - */ - private LocalMigrationResource createJdbcMigration(Resource resource, String filename) { - int pos = filename.lastIndexOf(".class"); - String mainName = filename.substring(0, pos); - MigrationVersion migrationVersion = MigrationVersion.parse(mainName); - String className = resource.location().replace('/', '.'); - className = className.substring(0, className.length() - 6); - JdbcMigration instance = migrationConfig.getJdbcMigrationFactory().createInstance(className); - return new LocalJdbcMigrationResource(migrationVersion, resource.location(), instance); - } - /** * Create a script based migration. */ - private LocalMigrationResource createScriptMigration(Resource resource, String filename) { - int pos = filename.lastIndexOf(".sql"); - String mainName = filename.substring(0, pos); + private LocalMigrationResource createScriptMigration(Resource resource, String mainName) { MigrationVersion migrationVersion = MigrationVersion.parse(mainName); return new LocalDdlMigrationResource(migrationVersion, resource.location(), resource); } @@ -193,30 +191,4 @@ List versions() { return versions; } - /** - * Filter used to find the migration scripts. - */ - private static final class Match implements Predicate { - - private final boolean searchJdbc; - - Match(boolean searchJdbc) { - this.searchJdbc = searchJdbc; - } - - @Override - public boolean test(String name) { - return name.endsWith(".sql") || (searchJdbc && name.endsWith(".class") && !name.contains("$")); - } - } - - /** - * Filter to find JDBC migrations only. - */ - private static final class JdbcOnly implements Predicate { - @Override - public boolean test(String name) { - return name.endsWith(".class") && !name.contains("$"); - } - } } diff --git a/ebean-migration/src/main/java/io/ebean/migration/runner/MigrationEngine.java b/ebean-migration/src/main/java/io/ebean/migration/runner/MigrationEngine.java index 8de37fb..804e520 100644 --- a/ebean-migration/src/main/java/io/ebean/migration/runner/MigrationEngine.java +++ b/ebean-migration/src/main/java/io/ebean/migration/runner/MigrationEngine.java @@ -10,12 +10,23 @@ import java.sql.SQLException; import java.util.List; -import static java.lang.System.Logger.Level.*; +import static java.lang.System.Logger.Level.DEBUG; +import static java.lang.System.Logger.Level.ERROR; +import static java.lang.System.Logger.Level.INFO; import static java.lang.System.Logger.Level.WARNING; import static java.util.Collections.emptyList; /** - * Actually runs the migrations. + * Actually runs the migrations and executes JDBC-mgirations. + *

+ * MigrationEngine needs a context that provides information for the current database, + * where the migration should run. When running on an existing {@link Connection}, a + * {@link DefaultMigrationContext} is created, but it is also possible, to pass a + * MigrationContext, that holds an ebeanserver. In this case, it is possible to use + * features like dto-queries in JDBC-migrations. + *

+ * In the other case, if only a raw jdbc-connection is used, you may not have access + * to these features and JDBC-migrations have to be done the traditional way. */ public class MigrationEngine { @@ -54,7 +65,7 @@ public List run(MigrationContext context) { long startMs = System.currentTimeMillis(); LocalMigrationResources resources = new LocalMigrationResources(migrationConfig); - if (!resources.readResources() && !resources.readInitResources()) { + if (!resources.readResources(context) && !resources.readInitResources()) { log.log(DEBUG, "no migrations to check"); return emptyList(); } @@ -74,7 +85,7 @@ public List run(MigrationContext context) { final MigrationTable table = initialiseMigrationTable(firstCheck, connection); try { List result = runMigrations(table, resources.versions()); - connection.commit(); + context.commit(); if (!checkStateOnly) { long commitMs = System.currentTimeMillis(); log.log(INFO, "DB migrations completed in {0}ms - executed:{1} totalMigrations:{2} mode:{3}", (commitMs - startMs), table.count(), table.size(), table.mode()); diff --git a/ebean-migration/src/main/java/io/ebean/migration/runner/MigrationTable.java b/ebean-migration/src/main/java/io/ebean/migration/runner/MigrationTable.java index 8dea179..090f4aa 100644 --- a/ebean-migration/src/main/java/io/ebean/migration/runner/MigrationTable.java +++ b/ebean-migration/src/main/java/io/ebean/migration/runner/MigrationTable.java @@ -426,7 +426,7 @@ private long executeMigration(LocalMigrationResource local, String script) throw if (local instanceof LocalJdbcMigrationResource) { JdbcMigration migration = ((LocalJdbcMigrationResource) local).migration(); log.log(INFO, "Executing jdbc migration version: {0} - {1}", local.version(), migration); - migration.migrate(context.connection()); + migration.migrate(context); } else { log.log(DEBUG, "run migration {0}", local.location()); scriptRunner.runScript(script, "run migration version: " + local.version()); diff --git a/ebean-migration/src/main/java/module-info.java b/ebean-migration/src/main/java/module-info.java index d20abf8..e0f8e22 100644 --- a/ebean-migration/src/main/java/module-info.java +++ b/ebean-migration/src/main/java/module-info.java @@ -8,5 +8,6 @@ requires transitive io.ebean.ddl.runner; requires io.ebean.migration.auto; + uses io.ebean.migration.JdbcMigration; provides io.ebean.migration.auto.AutoMigrationRunner with io.ebean.migration.AutoRunner; } diff --git a/ebean-migration/src/test/java/dbmig/V1_2_1__test.java b/ebean-migration/src/test/java/dbmig/V1_2_1__test.java index 9b917c2..cc87495 100644 --- a/ebean-migration/src/test/java/dbmig/V1_2_1__test.java +++ b/ebean-migration/src/test/java/dbmig/V1_2_1__test.java @@ -2,32 +2,24 @@ import java.sql.Connection; -import io.ebean.migration.ConfigurationAware; import io.ebean.migration.JdbcMigration; import io.ebean.migration.MigrationConfig; +import io.ebean.migration.MigrationContext; /** * Sample migration. * * @author Roland Praml, FOCONIS AG - * */ -public class V1_2_1__test implements JdbcMigration, ConfigurationAware{ - - private MigrationConfig config; +public class V1_2_1__test implements JdbcMigration { public static class MyDto { String id; } - - @Override - public void setMigrationConfig(MigrationConfig config) { - this.config = config; - } @Override - public void migrate(Connection connection) { - System.out.println("Executing migration on " + connection); + public void migrate(MigrationContext context) { + System.out.println("Executing migration on " + context); } @Override diff --git a/ebean-migration/src/test/java/io/ebean/migration/MigrationRunnerTest.java b/ebean-migration/src/test/java/io/ebean/migration/MigrationRunnerTest.java index 8738b2f..00eb6ea 100644 --- a/ebean-migration/src/test/java/io/ebean/migration/MigrationRunnerTest.java +++ b/ebean-migration/src/test/java/io/ebean/migration/MigrationRunnerTest.java @@ -1,5 +1,6 @@ package io.ebean.migration; +import dbmig.V1_2_1__test; import io.ebean.datasource.DataSourceConfig; import io.ebean.datasource.DataSourceFactory; import io.ebean.datasource.DataSourcePool; @@ -221,6 +222,8 @@ public void run_with_min_version() { try { MigrationConfig config = createMigrationConfig(); config.setMigrationPath("dbmig"); + config.setJdbcMigrations(List.of(new V1_2_1__test())); + config.setMinVersion("1.3"); // dbmig must run, if DB is empty! new MigrationRunner(config).run(dataSource); @@ -300,7 +303,7 @@ public void run_with_skipMigration() throws SQLException { // assert migrations are in the migration table try (final Connection connection = dataSource.getConnection()) { final List names = migrationNames(connection); - assertThat(names).contains("", "hello", "initial", "add_m3", "test", "m2_view"); + assertThat(names).containsExactly("", "hello", "initial", "add_m3", "serviceLoaded", "m2_view"); } // assert the migrations didn't actually run (create the tables etc) diff --git a/ebean-migration/src/test/java/io/ebean/migration/ServiceLoaderMigration.java b/ebean-migration/src/test/java/io/ebean/migration/ServiceLoaderMigration.java new file mode 100644 index 0000000..c23dbc7 --- /dev/null +++ b/ebean-migration/src/test/java/io/ebean/migration/ServiceLoaderMigration.java @@ -0,0 +1,24 @@ +package io.ebean.migration; + +import java.sql.Connection; + +/** + * @author Roland Praml, FOCONIS AG + */ +public class ServiceLoaderMigration implements JdbcMigration { + + @Override + public String getName() { + return "1.4.1__serviceLoaded"; + } + + @Override + public void migrate(MigrationContext context) { + + } + + @Override + public boolean matches(MigrationContext context) { + return "dbmig".equals(context.migrationPath()); + } +} diff --git a/ebean-migration/src/test/java/io/ebean/migration/runner/MigrationTable1Test.java b/ebean-migration/src/test/java/io/ebean/migration/runner/MigrationTable1Test.java index 81b72c5..d7880c0 100644 --- a/ebean-migration/src/test/java/io/ebean/migration/runner/MigrationTable1Test.java +++ b/ebean-migration/src/test/java/io/ebean/migration/runner/MigrationTable1Test.java @@ -1,5 +1,6 @@ package io.ebean.migration.runner; +import dbmig.V1_2_1__test; import io.ebean.migration.MigrationConfig; import io.ebean.migration.MigrationRunner; import io.ebean.datasource.DataSourceConfig; @@ -52,6 +53,7 @@ private MigrationTable migrationTable(Connection conn) { public void testMigrationTableBase() throws Exception { config.setMigrationPath("dbmig"); + config.setJdbcMigrations(List.of(new V1_2_1__test())); MigrationRunner runner = new MigrationRunner(config); runner.run(dataSource); diff --git a/ebean-migration/src/test/resources/META-INF/services/io.ebean.migration.JdbcMigration b/ebean-migration/src/test/resources/META-INF/services/io.ebean.migration.JdbcMigration new file mode 100644 index 0000000..6615228 --- /dev/null +++ b/ebean-migration/src/test/resources/META-INF/services/io.ebean.migration.JdbcMigration @@ -0,0 +1 @@ +io.ebean.migration.ServiceLoaderMigration diff --git a/pom.xml b/pom.xml index f561816..400119b 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,7 @@ ebean-migration-auto ebean-migration + ebean-migration-db