From d9a5cdaec82798e6b21693aa4241d5436064a42a Mon Sep 17 00:00:00 2001 From: Michael Bayne Date: Thu, 21 Dec 2006 19:06:18 +0000 Subject: [PATCH] Added support for hand-registered migrations (untested). --- .../jdbc/depot/DepotMarshaller.java | 289 +++++++++++------- .../jdbc/depot/EntityMigration.java | 50 +++ .../com/samskivert/jdbc/depot/Modifier.java | 22 ++ .../jdbc/depot/PersistenceContext.java | 68 ++++- .../jdbc/depot/annotation/Entity.java | 2 +- 5 files changed, 301 insertions(+), 130 deletions(-) create mode 100644 src/java/com/samskivert/jdbc/depot/EntityMigration.java diff --git a/src/java/com/samskivert/jdbc/depot/DepotMarshaller.java b/src/java/com/samskivert/jdbc/depot/DepotMarshaller.java index 62e6ce5..7300ba6 100644 --- a/src/java/com/samskivert/jdbc/depot/DepotMarshaller.java +++ b/src/java/com/samskivert/jdbc/depot/DepotMarshaller.java @@ -21,7 +21,6 @@ package com.samskivert.jdbc.depot; import java.lang.reflect.Field; -import java.lang.reflect.Modifier; import java.sql.Connection; import java.sql.DatabaseMetaData; @@ -37,6 +36,7 @@ import java.util.HashSet; import java.util.Set; import java.util.logging.Level; +import com.samskivert.io.PersistenceException; import com.samskivert.jdbc.depot.annotation.Computed; import com.samskivert.jdbc.depot.annotation.Entity; import com.samskivert.jdbc.depot.annotation.GeneratedValue; @@ -101,7 +101,8 @@ public class DepotMarshaller int mods = field.getModifiers(); // check for a static constant schema version - if ((mods & Modifier.STATIC) != 0 && field.getName().equals(SCHEMA_VERSION_FIELD)) { + if (java.lang.reflect.Modifier.isStatic(mods) && + field.getName().equals(SCHEMA_VERSION_FIELD)) { try { _schemaVersion = (Integer)field.get(null); } catch (Exception e) { @@ -111,7 +112,8 @@ public class DepotMarshaller } // the field must be public, non-static and non-transient - if (((mods & Modifier.PUBLIC) == 0) || ((mods & Modifier.STATIC) != 0) || + if (!java.lang.reflect.Modifier.isPublic(mods) || + java.lang.reflect.Modifier.isStatic(mods) || field.getAnnotation(Transient.class) != null) { continue; } @@ -292,104 +294,12 @@ public class DepotMarshaller } /** - * Initializes the table used by this marshaller. If the table does not exist, it will be - * created. If the schema version specified by the persistent object is newer than the database - * schema, it will be migrated. + * Returns true if this marshaller has been initialized ({@link #init} has been called), its + * migrations run and it is ready for operation. False otherwise. */ - public void init (Connection conn) - throws SQLException + public boolean isInitialized () { - // if we have no table (i.e. we're a computed entity), we have nothing to create - if (getTableName() == null) { - return; - } - - // check to see if our schema version table exists, create it if not - JDBCUtil.createTableIfMissing(conn, SCHEMA_VERSION_TABLE, - new String[] { "persistentClass VARCHAR(255) NOT NULL", - "version INTEGER NOT NULL" }, ""); - - // now create the table for our persistent class if it does not exist - if (!JDBCUtil.tableExists(conn, getTableName())) { - log.fine("Creating table " + getTableName() + " (" + - StringUtil.join(_columnDefinitions, ", ") + ") " + _postamble); - JDBCUtil.createTableIfMissing(conn, getTableName(), _columnDefinitions, _postamble); - updateVersion(conn, 1); - } - - // if we have a key generator, initialize that too - if (_keyGenerator != null) { - _keyGenerator.init(conn); - } - - // if schema versioning is disabled, stop now - if (_schemaVersion < 0) { - return; - } - - // make sure the versions match - int currentVersion = readVersion(conn); - if (currentVersion == _schemaVersion) { - return; - } - - log.info("Migrating " + getTableName() + " from " + - currentVersion + " to " + _schemaVersion + "..."); - - // otherwise try to migrate the schema; doing column additions magically and running any - // registered hand-migrations - DatabaseMetaData meta = conn.getMetaData(); - ResultSet rs = meta.getColumns(null, null, getTableName(), "%"); - HashSet columns = new HashSet(); - while (rs.next()) { - columns.add(rs.getString("COLUMN_NAME")); - } - - for (String fname : _columnFields) { - FieldMarshaller fmarsh = _fields.get(fname); - if (columns.contains(fmarsh.getColumnName())) { - continue; - } - - // otherwise add the column - String coldef = fmarsh.getColumnDefinition(); - String query = "alter table " + getTableName() + " add column " + coldef; - - // try to add it to the appropriate spot - int fidx = ListUtil.indexOf(_allFields, fmarsh.getColumnName()); - if (fidx == 0) { - query += " first"; - } else { - query += " after " + _allFields[fidx-1]; - } - - log.info("Adding column to " + getTableName() + ": " + coldef); - Statement stmt = conn.createStatement(); - try { - stmt.executeUpdate(query); - } finally { - stmt.close(); - } - - // if the column is a TIMESTAMP column, we need to run a special query to update all - // existing rows to the current time because MySQL annoyingly assigns them a default - // value of "0000-00-00 00:00:00" regardless of whether we explicitly provide a - // "DEFAULT" value for the column or not - if (coldef.toLowerCase().indexOf(" timestamp") != -1) { - query = "update " + getTableName() + " set " + fmarsh.getColumnName() + " = NOW()"; - log.info("Assigning current time to TIMESTAMP column: " + query); - stmt = conn.createStatement(); - try { - stmt.executeUpdate(query); - } finally { - stmt.close(); - } - } - } - - // TODO: run any registered hand migrations - - updateVersion(conn, _schemaVersion); + return _initialized; } /** @@ -628,6 +538,167 @@ public class DepotMarshaller return pstmt; } + /** + * This is called by the persistence context to register a migration for the entity managed by + * this marshaller. + */ + protected void registerMigration (EntityMigration migration) + { + _migrations.add(migration); + } + + /** + * Initializes the table used by this marshaller. This is called automatically by the {@link + * PersistenceContext} the first time an entity is used. If the table does not exist, it will + * be created. If the schema version specified by the persistent object is newer than the + * database schema, it will be migrated. + */ + protected void init (PersistenceContext ctx) + throws PersistenceException + { + if (_initialized) { // sanity check + throw new IllegalStateException( + "Cannot re-initialize marshaller [type=" + _pclass + "]."); + } + _initialized = true; + + // if we have no table (i.e. we're a computed entity), we have nothing to create + if (getTableName() == null) { + return; + } + + // check to see if our schema version table exists, create it if not + ctx.invoke(new Modifier(null) { + public int invoke (Connection conn) throws SQLException { + JDBCUtil.createTableIfMissing( + conn, SCHEMA_VERSION_TABLE, + new String[] { "persistentClass VARCHAR(255) NOT NULL", + "version INTEGER NOT NULL" }, ""); + return 0; + } + }); + + // now create the table for our persistent class if it does not exist + ctx.invoke(new Modifier(null) { + public int invoke (Connection conn) throws SQLException { + if (!JDBCUtil.tableExists(conn, getTableName())) { + log.fine("Creating table " + getTableName() + " (" + + StringUtil.join(_columnDefinitions, ", ") + ") " + _postamble); + JDBCUtil.createTableIfMissing( + conn, getTableName(), _columnDefinitions, _postamble); + updateVersion(conn, 1); + } + return 0; + } + }); + + // if we have a key generator, initialize that too + if (_keyGenerator != null) { + ctx.invoke(new Modifier(null) { + public int invoke (Connection conn) throws SQLException { + _keyGenerator.init(conn); + return 0; + } + }); + } + + // if schema versioning is disabled, stop now + if (_schemaVersion < 0) { + return; + } + + // make sure the versions match + int currentVersion = ctx.invoke(new Modifier(null) { + public int invoke (Connection conn) throws SQLException { + String query = "select version from " + SCHEMA_VERSION_TABLE + + " where persistentClass = '" + getTableName() + "'"; + Statement stmt = conn.createStatement(); + try { + ResultSet rs = stmt.executeQuery(query); + return (rs.next()) ? rs.getInt(1) : 1; + } finally { + stmt.close(); + } + } + }); + if (currentVersion == _schemaVersion) { + return; + } + + log.info("Migrating " + getTableName() + " from " + + currentVersion + " to " + _schemaVersion + "..."); + + // otherwise try to migrate the schema + final HashSet columns = new HashSet(); + ctx.invoke(new Modifier(null) { + public int invoke (Connection conn) throws SQLException { + DatabaseMetaData meta = conn.getMetaData(); + ResultSet rs = meta.getColumns(null, null, getTableName(), "%"); + while (rs.next()) { + columns.add(rs.getString("COLUMN_NAME")); + } + return 0; + } + }); + + // run our pre-default-migrations + for (EntityMigration migration : _migrations) { + if (migration.runBeforeDefault() && + migration.shouldRunMigration(currentVersion, _schemaVersion)) { + ctx.invoke(migration); + } + } + + // do our "default" migrations magically + for (String fname : _columnFields) { + FieldMarshaller fmarsh = _fields.get(fname); + if (columns.contains(fmarsh.getColumnName())) { + continue; + } + + // otherwise add the column + String coldef = fmarsh.getColumnDefinition(); + String query = "alter table " + getTableName() + " add column " + coldef; + + // try to add it to the appropriate spot + int fidx = ListUtil.indexOf(_allFields, fmarsh.getColumnName()); + if (fidx == 0) { + query += " first"; + } else { + query += " after " + _allFields[fidx-1]; + } + + log.info("Adding column to " + getTableName() + ": " + coldef); + ctx.invoke(new Modifier.Simple(query)); + + // if the column is a TIMESTAMP column, we need to run a special query to update all + // existing rows to the current time because MySQL annoyingly assigns them a default + // value of "0000-00-00 00:00:00" regardless of whether we explicitly provide a + // "DEFAULT" value for the column or not + if (coldef.toLowerCase().indexOf(" timestamp") != -1) { + query = "update " + getTableName() + " set " + fmarsh.getColumnName() + " = NOW()"; + log.info("Assigning current time to TIMESTAMP column: " + query); + ctx.invoke(new Modifier.Simple(query)); + } + } + + // run our post-default-migrations + for (EntityMigration migration : _migrations) { + if (!migration.runBeforeDefault() && + migration.shouldRunMigration(currentVersion, _schemaVersion)) { + ctx.invoke(migration); + } + } + + // record our new version in the database + ctx.invoke(new Modifier(null) { + public int invoke (Connection conn) throws SQLException { + updateVersion(conn, _schemaVersion); + return 0; + } + }); + } + protected void updateVersion (Connection conn, int version) throws SQLException { @@ -645,20 +716,6 @@ public class DepotMarshaller } } - protected int readVersion (Connection conn) - throws SQLException - { - String query = "select version from " + SCHEMA_VERSION_TABLE + - " where persistentClass = '" + getTableName() + "'"; - Statement stmt = conn.createStatement(); - try { - ResultSet rs = stmt.executeQuery(query); - return (rs.next()) ? rs.getInt(1) : 1; - } finally { - stmt.close(); - } - } - protected void requireNotComputed (String action) throws SQLException { @@ -700,6 +757,12 @@ public class DepotMarshaller /** Used when creating and migrating our table schema. */ protected String _postamble = ""; + /** Indicates that we have been initialized (created or migrated our tables). */ + protected boolean _initialized; + + /** A list of hand registered entity migrations to run prior to doing the default migration. */ + protected ArrayList _migrations = new ArrayList(); + /** The name of the table we use to track schema versions. */ protected static final String SCHEMA_VERSION_TABLE = "DepotSchemaVersion"; } diff --git a/src/java/com/samskivert/jdbc/depot/EntityMigration.java b/src/java/com/samskivert/jdbc/depot/EntityMigration.java new file mode 100644 index 0000000..588d266 --- /dev/null +++ b/src/java/com/samskivert/jdbc/depot/EntityMigration.java @@ -0,0 +1,50 @@ +// +// $Id$ +// +// samskivert library - useful routines for java programs +// Copyright (C) 2006 Michael Bayne +// +// This library is free software; you can redistribute it and/or modify it +// under the terms of the GNU Lesser General Public License as published +// by the Free Software Foundation; either version 2.1 of the License, or +// (at your option) any later version. +// +// This library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with this library; if not, write to the Free Software +// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + +package com.samskivert.jdbc.depot; + +/** + * These can be registered with the {@link PersistenceContext} to effect hand-coded migrations + * between entity versions. The modifier should override {@link #invoke} to perform its + * migrations. See {@link PersistenceContext#registerPreMigration} and {@link + * PersistenceContext#registerPostMigration}. + */ +public abstract class EntityMigration extends Modifier +{ + /** + * If this method returns true, this migration will be run before the default + * migrations, if false it will be run after. + */ + public boolean runBeforeDefault () + { + return true; + } + + /** + * When an Entity is being migrated, this method will be called to check whether this migration + * should be run. + */ + public abstract boolean shouldRunMigration (int currentVersion, int targetVersion); + + protected EntityMigration () + { + super(null); + } +} diff --git a/src/java/com/samskivert/jdbc/depot/Modifier.java b/src/java/com/samskivert/jdbc/depot/Modifier.java index 09b68af..a5b208e 100644 --- a/src/java/com/samskivert/jdbc/depot/Modifier.java +++ b/src/java/com/samskivert/jdbc/depot/Modifier.java @@ -22,6 +22,7 @@ package com.samskivert.jdbc.depot; import java.sql.Connection; import java.sql.SQLException; +import java.sql.Statement; import com.samskivert.jdbc.depot.clause.Where; @@ -30,6 +31,27 @@ import com.samskivert.jdbc.depot.clause.Where; */ public abstract class Modifier { + /** A simple modifier that executes a single SQL statement. No cache flushing is done as a + * result of this operation. */ + public static class Simple extends Modifier + { + public Simple (String query) { + super(null); + _query = query; + } + + public int invoke (Connection conn) throws SQLException { + Statement stmt = conn.createStatement(); + try { + return stmt.executeUpdate(_query); + } finally { + stmt.close(); + } + } + + protected String _query; + } + public abstract int invoke (Connection conn) throws SQLException; protected Modifier (Where key) diff --git a/src/java/com/samskivert/jdbc/depot/PersistenceContext.java b/src/java/com/samskivert/jdbc/depot/PersistenceContext.java index fa543ad..94468ef 100644 --- a/src/java/com/samskivert/jdbc/depot/PersistenceContext.java +++ b/src/java/com/samskivert/jdbc/depot/PersistenceContext.java @@ -57,26 +57,48 @@ public class PersistenceContext } /** - * Returns the marshaller for the specified persistent object class, - * creating and initializing it if necessary. + * Registers a migration for the specified entity class. + * + *

This method must be called before an Entity is used by any repository. Thus you + * should register all migrations immediately after creating your persistence context or if you + * are careful to ensure that your Entities are only used by a single repository, you can + * register your migrations in the constructor for that repository. + * + *

Note that the migration process is as follows: + * + *

  • Note the difference between the entity's declared version and the version recorded + * in the database. + *
  • Run all registered pre-migrations + *
  • Perform all default migrations (column additions and removals) + *
  • Run all registered post-migrations
+ * + * Thus you must either be prepared for the entity to be at any version prior to your + * migration target version because we may start up, find the schema at version 1 and the + * Entity class at version 8 and do all "standard" migrations in one fell swoop. So if a column + * got added in version 2 and renamed in version 6 and your migration was registered for + * version 6 to do that migration, it must be prepared for the column not to exist at all. + * + *

If you want a completely predictable migration process, never use the default migrations + * and register a pre-migration for every single schema migration and they will then be + * guaranteed to be run in registration order and with predictable pre- and post-conditions. + */ + public void registerMigration (Class type, EntityMigration migration) + { + getRawMarshaller(type).registerMigration(migration); + } + + /** + * Returns the marshaller for the specified persistent object class, creating and initializing + * it if necessary. */ public DepotMarshaller getMarshaller (Class type) throws PersistenceException { - @SuppressWarnings("unchecked") DepotMarshaller marshaller = - (DepotMarshaller)_marshallers.get(type); - if (marshaller == null) { - _marshallers.put( - type, marshaller = new DepotMarshaller(type, this)); - // initialize the marshaller which may create or migrate the table - // for its underlying persistent object - final DepotMarshaller fm = marshaller; - invoke(new Modifier(null) { - public int invoke (Connection conn) throws SQLException { - fm.init(conn); - return 0; - } - }); + DepotMarshaller marshaller = getRawMarshaller(type); + if (!marshaller.isInitialized()) { + // initialize the marshaller which may create or migrate the table for its underlying + // persistent object + marshaller.init(this); } return marshaller; } @@ -105,6 +127,20 @@ public class PersistenceContext return result; } + /** + * Looks up and creates, but does not initialize, the marshaller for the specified Entity + * type. + */ + protected DepotMarshaller getRawMarshaller (Class type) + { + @SuppressWarnings("unchecked") DepotMarshaller marshaller = + (DepotMarshaller)_marshallers.get(type); + if (marshaller == null) { + _marshallers.put(type, marshaller = new DepotMarshaller(type, this)); + } + return marshaller; + } + /** * Internal invoke method that takes care of transient retries * for both queries and modifiers. diff --git a/src/java/com/samskivert/jdbc/depot/annotation/Entity.java b/src/java/com/samskivert/jdbc/depot/annotation/Entity.java index 7530265..81ddb7f 100644 --- a/src/java/com/samskivert/jdbc/depot/annotation/Entity.java +++ b/src/java/com/samskivert/jdbc/depot/annotation/Entity.java @@ -36,6 +36,6 @@ public @interface Entity String name () default ""; /** Additional SQL to be added when creating this entity's table for the first time. You can - * specify additional indices here or supply a MySQL table storage type for example. */ + * specify a MySQL table storage type for example. */ String postamble () default ""; }