diff --git a/pom.xml b/pom.xml index 1df6c124d..89e20eaf9 100644 --- a/pom.xml +++ b/pom.xml @@ -147,12 +147,14 @@ 4.7.1 test - + + com.h2database h2 - 1.4.182 - test + 1.4.189 + provided diff --git a/src/main/java/com/avaje/ebean/config/dbplatform/DbViewHistorySupport.java b/src/main/java/com/avaje/ebean/config/dbplatform/DbViewHistorySupport.java index 210027c66..9d9f0a7d6 100644 --- a/src/main/java/com/avaje/ebean/config/dbplatform/DbViewHistorySupport.java +++ b/src/main/java/com/avaje/ebean/config/dbplatform/DbViewHistorySupport.java @@ -55,7 +55,7 @@ public abstract class DbViewHistorySupport implements DbHistorySupport { public String getAsOfPredicate(String asOfTableAlias, String asOfSysPeriod) { // (sys_period_start < ? and (sys_period_end is null or sys_period_end > ?)); - return "(" + asOfTableAlias + "." + asOfSysPeriod + "_start" + " < ? and (" + asOfTableAlias + "." + asOfSysPeriod + "_end" + " is null or " + asOfTableAlias + "." + asOfSysPeriod + "_end" + " > ?))"; + return "(" + asOfTableAlias + "." + asOfSysPeriod + "_start" + " <= ? and (" + asOfTableAlias + "." + asOfSysPeriod + "_end" + " is null or " + asOfTableAlias + "." + asOfSysPeriod + "_end" + " > ?))"; } /** diff --git a/src/main/java/com/avaje/ebean/config/dbplatform/H2HistorySupport.java b/src/main/java/com/avaje/ebean/config/dbplatform/H2HistorySupport.java new file mode 100644 index 000000000..b8aa4a023 --- /dev/null +++ b/src/main/java/com/avaje/ebean/config/dbplatform/H2HistorySupport.java @@ -0,0 +1,8 @@ +package com.avaje.ebean.config.dbplatform; + +/** + * Runtime support for @History with H2. + */ +public class H2HistorySupport extends DbViewHistorySupport { + +} diff --git a/src/main/java/com/avaje/ebean/config/dbplatform/H2HistoryTrigger.java b/src/main/java/com/avaje/ebean/config/dbplatform/H2HistoryTrigger.java new file mode 100644 index 000000000..2e091dead --- /dev/null +++ b/src/main/java/com/avaje/ebean/config/dbplatform/H2HistoryTrigger.java @@ -0,0 +1,127 @@ +package com.avaje.ebean.config.dbplatform; + +import org.h2.api.Trigger; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * H2 database trigger used to populate history tables to support the @History feature. + */ +public class H2HistoryTrigger implements Trigger { + + private static final Logger logger = LoggerFactory.getLogger(H2HistoryTrigger.class); + + /** + * Hardcoding the column and history table suffix for now. Not sure how to get that + * configuration into the trigger instance nicely as it is instantiated by H2. + */ + private static final String SYS_PERIOD_START = "SYS_PERIOD_START"; + private static final String SYS_PERIOD_END = "SYS_PERIOD_END"; + private static final String HISTORY_SUFFIX = "_history"; + + /** + * SQL to insert into the history table. + */ + private String insertHistorySql; + + /** + * Position of SYS_PERIOD_START column in the Object[]. + */ + private int effectStartPosition; + + /** + * Position of SYS_PERIOD_END column in the Object[]. + */ + private int effectEndPosition; + + @Override + public void init(Connection conn, String schemaName, String triggerName, String tableName, boolean before, int type) throws SQLException { + + // get the columns for the table + ResultSet rs = conn.getMetaData().getColumns(null, schemaName, tableName, null); + + // build the insert into history table SQL + StringBuilder insertSql = new StringBuilder(150); + insertSql.append("insert into ").append(tableName).append(HISTORY_SUFFIX).append(" ("); + + int count = 0; + List columns = new ArrayList(); + while (rs.next()) { + if (++count > 1) { + insertSql.append(","); + } + String columnName = rs.getString("COLUMN_NAME"); + if (columnName.equalsIgnoreCase(SYS_PERIOD_START)) { + this.effectStartPosition = count - 1; + } else if (columnName.equalsIgnoreCase(SYS_PERIOD_END)) { + this.effectEndPosition = count - 1; + } + insertSql.append(columnName); + columns.add(columnName); + } + insertSql.append(") values ("); + for (int i = 0; i < count; i++) { + if (i > 0) { + insertSql.append(","); + } + insertSql.append("?"); + } + insertSql.append(");"); + + this.insertHistorySql = insertSql.toString(); + logger.debug("History table insert sql: {}", insertHistorySql); + } + + @Override + public void fire(Connection connection, Object[] oldRow, Object[] newRow) throws SQLException { + + if (oldRow != null) { + // a delete or update event + Timestamp now = new Timestamp(System.currentTimeMillis()); + oldRow[effectEndPosition] = now; + if (newRow != null) { + // update event. Set the effective start timestamp to now. + newRow[effectStartPosition] = now; + } + if (logger.isDebugEnabled()) { + logger.debug("History insert: {}", Arrays.toString(oldRow)); + } + insertIntoHistory(connection, oldRow); + } + } + + /** + * Insert the data into the history table. + */ + private void insertIntoHistory(Connection connection, Object[] oldRow) throws SQLException { + + PreparedStatement stmt = connection.prepareStatement(insertHistorySql); + try { + for (int i = 0; i < oldRow.length; i++) { + stmt.setObject(i + 1, oldRow[i]); + } + stmt.executeUpdate(); + } finally { + stmt.close(); + } + } + + @Override + public void close() throws SQLException { + + } + + @Override + public void remove() throws SQLException { + + } +} diff --git a/src/main/java/com/avaje/ebean/config/dbplatform/H2Platform.java b/src/main/java/com/avaje/ebean/config/dbplatform/H2Platform.java index 521f8b55c..8e8189032 100644 --- a/src/main/java/com/avaje/ebean/config/dbplatform/H2Platform.java +++ b/src/main/java/com/avaje/ebean/config/dbplatform/H2Platform.java @@ -15,6 +15,7 @@ public class H2Platform extends DatabasePlatform { this.name = "h2"; this.dbEncrypt = new H2DbEncrypt(); this.platformDdl = new H2Ddl(this.dbTypeMap, dbIdentity); + this.historySupport = new H2HistorySupport(); // only support getGeneratedKeys with non-batch JDBC // so generally use SEQUENCE instead of IDENTITY for H2 diff --git a/src/main/java/com/avaje/ebean/dbmigration/ddlgeneration/platform/H2Ddl.java b/src/main/java/com/avaje/ebean/dbmigration/ddlgeneration/platform/H2Ddl.java index d274b7b66..cfeaf1a1f 100644 --- a/src/main/java/com/avaje/ebean/dbmigration/ddlgeneration/platform/H2Ddl.java +++ b/src/main/java/com/avaje/ebean/dbmigration/ddlgeneration/platform/H2Ddl.java @@ -10,6 +10,7 @@ public class H2Ddl extends PlatformDdl { public H2Ddl(DbTypeMap platformTypes, DbIdentity dbIdentity) { super(platformTypes, dbIdentity); + this.historyDdl = new H2HistoryDdl(); } } diff --git a/src/main/java/com/avaje/ebean/dbmigration/ddlgeneration/platform/H2HistoryDdl.java b/src/main/java/com/avaje/ebean/dbmigration/ddlgeneration/platform/H2HistoryDdl.java new file mode 100644 index 000000000..46b7759af --- /dev/null +++ b/src/main/java/com/avaje/ebean/dbmigration/ddlgeneration/platform/H2HistoryDdl.java @@ -0,0 +1,73 @@ +package com.avaje.ebean.dbmigration.ddlgeneration.platform; + +import com.avaje.ebean.config.dbplatform.H2HistoryTrigger; +import com.avaje.ebean.dbmigration.ddlgeneration.DdlBuffer; +import com.avaje.ebean.dbmigration.ddlgeneration.DdlWrite; +import com.avaje.ebean.dbmigration.model.MTable; + +import java.io.IOException; +import java.util.List; + +/** + * H2 history support using DB triggers to maintain a history table. + */ +public class H2HistoryDdl extends DbTriggerBasedHistoryDdl { + + private static final String TRIGGER_CLASS = H2HistoryTrigger.class.getName(); + + public H2HistoryDdl() { + } + + @Override + protected void dropTriggers(DdlBuffer buffer, String baseTable) throws IOException { + + buffer.append("drop trigger ").append(updateTriggerName(baseTable)).endOfStatement(); + } + + @Override + protected void createTriggers(DdlWrite writer, MTable table) throws IOException { + + String baseTableName = table.getName(); + String historyTableName = historyTableName(baseTableName); + List includedColumns = includedColumnNames(table); + + DdlBuffer apply = writer.applyHistory(); + + addCreateTrigger(apply, updateTriggerName(baseTableName), baseTableName, historyTableName, includedColumns); + } + + @Override + protected void regenerateHistoryTriggers(DdlWrite writer, MTable table, HistoryTableUpdate update) throws IOException { + + String baseTableName = table.getName(); + String historyTableName = historyTableName(baseTableName); + List includedColumns = includedColumnNames(table); + + DdlBuffer apply = writer.applyHistory(); + + apply.append("-- Regenerated ").newLine(); + apply.append("-- changes: ").append(update.description()).newLine(); + + dropTriggers(apply, baseTableName); + addCreateTrigger(apply, updateTriggerName(baseTableName), baseTableName, historyTableName, includedColumns); + + // put a reverted version into the rollback buffer + update.toRevertedColumns(includedColumns); + + DdlBuffer rollback = writer.rollback(); + rollback.append("-- Revert regenerated ").newLine(); + rollback.append("-- revert changes: ").append(update.description()).newLine(); + dropTriggers(rollback, baseTableName); + addCreateTrigger(rollback, updateTriggerName(baseTableName), baseTableName, historyTableName, includedColumns); + } + + private void addCreateTrigger(DdlBuffer apply, String triggerName, String baseTable, String historyTable, List includedColumns) throws IOException { + + // Note that this does not take into account the historyTable name (excepts _history suffix) and + // does not take into account excluded columns (all columns included in history) + apply + .append("create trigger ").append(triggerName).append(" before update,delete on ").append(baseTable) + .append(" for each row call \"" + TRIGGER_CLASS + "\";").newLine(); + } + +} diff --git a/src/test/java/com/avaje/ebean/dbmigration/model/ModelContainerApplyTest.java b/src/test/java/com/avaje/ebean/dbmigration/model/ModelContainerApplyTest.java index 0e35e847f..bec2f3760 100644 --- a/src/test/java/com/avaje/ebean/dbmigration/model/ModelContainerApplyTest.java +++ b/src/test/java/com/avaje/ebean/dbmigration/model/ModelContainerApplyTest.java @@ -35,7 +35,7 @@ public class ModelContainerApplyTest { assertThat(foo.getComment()).isEqualTo("comment"); assertThat(foo.getTablespace()).isEqualTo("fooSpace"); assertThat(foo.getIndexTablespace()).isEqualTo("fooIndexSpace"); - assertThat(foo.isWithHistory()).isEqualTo(true); + assertThat(foo.isWithHistory()).isEqualTo(false); assertThat(foo.getColumns()).containsKeys("col1", "col3", "added_to_foo"); } } \ No newline at end of file diff --git a/src/test/java/com/avaje/tests/history/TestHistoryInsert.java b/src/test/java/com/avaje/tests/history/TestHistoryInsert.java new file mode 100644 index 000000000..9fda60e7b --- /dev/null +++ b/src/test/java/com/avaje/tests/history/TestHistoryInsert.java @@ -0,0 +1,83 @@ +package com.avaje.tests.history; + +import com.avaje.ebean.BaseTestCase; +import com.avaje.ebean.Ebean; +import com.avaje.ebean.SqlQuery; +import com.avaje.ebean.SqlRow; +import com.avaje.ebean.Version; +import com.avaje.tests.model.converstation.User; +import org.junit.Test; + +import java.sql.Timestamp; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TestHistoryInsert extends BaseTestCase { + + @Test + public void test() { + + User user = new User(); + user.setName("Jim"); + user.setEmail("one@email.com"); + + Ebean.save(user); + + Timestamp afterInsert = new Timestamp(System.currentTimeMillis()); + + List history = fetchHistory(user); + assertThat(history).isEmpty(); + + List> versions = Ebean.find(User.class).setId(user.getId()).findVersions(); + assertThat(versions).hasSize(1); + + user.setName("Jim v2"); + Ebean.save(user); + + history = fetchHistory(user); + assertThat(history).hasSize(1); + assertThat(history.get(0).getString("name")).isEqualTo("Jim"); + + versions = Ebean.find(User.class).setId(user.getId()).findVersions(); + assertThat(versions).hasSize(2); + assertThat(versions.get(0).getDiff()).containsKeys("name", "version", "whenModified"); + + user.setName("Jim v3"); + user.setEmail("three@email.com"); + Ebean.save(user); + + history = fetchHistory(user); + assertThat(history).hasSize(2); + assertThat(history.get(1).getString("name")).isEqualTo("Jim v2"); + assertThat(history.get(1).getString("email")).isEqualTo("one@email.com"); + + versions = Ebean.find(User.class).setId(user.getId()).findVersions(); + assertThat(versions).hasSize(3); + assertThat(versions.get(0).getDiff()).containsKeys("name", "email", "version", "whenModified"); + + Ebean.delete(user); + + User earlyVersion = Ebean.find(User.class).setId(user.getId()).asOf(afterInsert).findUnique(); + assertThat(earlyVersion.getName()).isEqualTo("Jim"); + assertThat(earlyVersion.getEmail()).isEqualTo("one@email.com"); + + + history = fetchHistory(user); + assertThat(history).hasSize(3); + assertThat(history.get(2).getString("name")).isEqualTo("Jim v3"); + assertThat(history.get(2).getString("email")).isEqualTo("three@email.com"); + + versions = Ebean.find(User.class).setId(user.getId()).findVersions(); + assertThat(versions).hasSize(3); + } + + /** + * Use SqlQuery to query the history table directly. + */ + private List fetchHistory(User user) { + SqlQuery sqlQuery = Ebean.createSqlQuery("select * from c_user_history where id = :id order by sys_period_start"); + sqlQuery.setParameter("id", user.getId()); + return sqlQuery.findList(); + } +} diff --git a/src/test/java/com/avaje/tests/model/converstation/User.java b/src/test/java/com/avaje/tests/model/converstation/User.java index 0b94cabeb..3e365901c 100644 --- a/src/test/java/com/avaje/tests/model/converstation/User.java +++ b/src/test/java/com/avaje/tests/model/converstation/User.java @@ -4,8 +4,10 @@ import javax.persistence.Entity; import javax.persistence.ManyToOne; import javax.persistence.Table; +import com.avaje.ebean.annotation.History; import com.avaje.tests.model.BaseModel; +@History @Entity @Table(name="c_user") public class User extends BaseModel { diff --git a/src/test/resources/container/test-create-table.xml b/src/test/resources/container/test-create-table.xml index 22ad62f4a..9c5bc2e30 100644 --- a/src/test/resources/container/test-create-table.xml +++ b/src/test/resources/container/test-create-table.xml @@ -4,7 +4,7 @@ - + diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index 3fadff078..0feee6d67 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -86,6 +86,6 @@ - + \ No newline at end of file