001package systems.dmx.core.impl;
002
003import systems.dmx.core.service.Migration;
004import systems.dmx.core.service.ModelFactory;
005import systems.dmx.core.service.Plugin;
006import systems.dmx.core.util.JavaUtils;
007
008import org.codehaus.jettison.json.JSONArray;
009import org.codehaus.jettison.json.JSONException;
010import org.codehaus.jettison.json.JSONObject;
011import org.codehaus.jettison.json.JSONTokener;
012
013import java.io.InputStream;
014import java.io.IOException;
015import java.lang.reflect.Field;
016import java.util.Properties;
017import java.util.logging.Logger;
018
019
020
021class MigrationManager {
022
023    // ------------------------------------------------------------------------------------------------------- Constants
024
025    private static final String CORE_MIGRATIONS_PACKAGE = "systems.dmx.core.migrations";
026    private static final int CORE_MODEL_VERSION = 7;
027
028    // ---------------------------------------------------------------------------------------------- Instance Variables
029
030    private CoreServiceImpl dmx;
031    private ModelFactory mf;
032
033    private enum MigrationRunMode {
034        CLEAN_INSTALL, UPDATE, ALWAYS
035    }
036
037    private Logger logger = Logger.getLogger(getClass().getName());
038
039    // ---------------------------------------------------------------------------------------------------- Constructors
040
041    MigrationManager(CoreServiceImpl dmx) {
042        this.dmx = dmx;
043        this.mf = dmx.mf;
044    }
045
046    // ----------------------------------------------------------------------------------------- Package Private Methods
047
048    /**
049     * Determines the migrations to be run for the specified plugin, and runs them.
050     */
051    void runPluginMigrations(PluginImpl plugin, boolean isCleanInstall) {
052        int installedModelVersion = plugin.getPluginTopic().getChildTopics().getTopic("dmx.core.plugin_migration_nr")
053            .getSimpleValue().intValue();
054        int requiredModelVersion = Integer.parseInt(plugin.getConfigProperty("dmx.plugin.model_version", "0"));
055        int migrationsToRun = requiredModelVersion - installedModelVersion;
056        //
057        if (migrationsToRun == 0) {
058            logger.info("Running migrations for " + plugin + " SKIPPED -- installed model is up-to-date (version " +
059                installedModelVersion + ")");
060            return;
061        }
062        //
063        logger.info("Running " + migrationsToRun + " migrations for " + plugin + " (installed model: version " +
064            installedModelVersion + ", required model: version " + requiredModelVersion + ")");
065        for (int i = installedModelVersion + 1; i <= requiredModelVersion; i++) {
066            runMigration(i, plugin, isCleanInstall);
067        }
068    }
069
070    /**
071     * Determines the core migrations to be run, and runs them.
072     */
073    void runCoreMigrations(boolean isCleanInstall) {
074        int installedModelVersion = dmx.pl.fetchMigrationNr();
075        int requiredModelVersion = CORE_MODEL_VERSION;
076        int migrationsToRun = requiredModelVersion - installedModelVersion;
077        //
078        if (migrationsToRun == 0) {
079            logger.info("Running core migrations SKIPPED -- installed model is up-to-date (version " +
080                installedModelVersion + ")");
081            return;
082        }
083        //
084        logger.info("Running " + migrationsToRun + " core migrations (installed model: version " +
085            installedModelVersion + ", required model: version " + requiredModelVersion + ")");
086        for (int i = installedModelVersion + 1; i <= requiredModelVersion; i++) {
087            runMigration(i, null, isCleanInstall);      // plugin=null
088        }
089    }
090
091    // ------------------------------------------------------------------------------------------------- Private Methods
092
093    /**
094     * Collects the info needed to run a migration, runs it, and then updates the version number in the DB.
095     *
096     * @param   migrationNr     Number of the migration to run.
097     * @param   plugin          The plugin that provides the migration to run.
098     *                          <code>null</code> for a core migration.
099     * @param   isCleanInstall  <code>true</code> if the migration is run as part of a clean install,
100     *                          <code>false</code> if the migration is run as part of an update.
101     */
102    private void runMigration(int migrationNr, PluginImpl plugin, boolean isCleanInstall) {
103        MigrationInfo mi = null;
104        try {
105            // collect info
106            mi = new MigrationInfo(migrationNr, plugin);
107            if (!mi.success) {
108                throw mi.exception;     // is not a runtime exception
109            }
110            mi.checkValidity();
111            //
112            _runMigration(mi, isCleanInstall);
113            //
114            updateVersionNumber(mi);
115        } catch (Exception e) {
116            // Note: mi is instantiated for sure
117            throw new RuntimeException("Running " + mi.migrationInfo + " failed", e);
118        }
119    }
120
121    /**
122     * Runs a migration.
123     */
124    private void _runMigration(MigrationInfo mi, boolean isCleanInstall) throws Exception {
125        String runInfo = " (runMode=" + mi.runMode + ", isCleanInstall=" + isCleanInstall + ")";
126        if (mi.runMode.equals(MigrationRunMode.CLEAN_INSTALL.name()) == isCleanInstall ||
127            mi.runMode.equals(MigrationRunMode.ALWAYS.name())) {
128            logger.info("Running " + mi.migrationInfo + runInfo);
129            if (mi.isDeclarative) {
130                readMigrationFile(mi.migrationIn, mi.migrationFile);
131            } else {
132                Migration migration = (Migration) mi.migrationClass.newInstance(); // throws InstantiationException, ...
133                injectServices(migration, mi.migrationInfo, mi.plugin);
134                migration.setCoreService(dmx);
135                logger.info("Running " + mi.migrationType + " migration class " + mi.migrationClassName);
136                migration.run();
137            }
138        } else {
139            logger.info("Running " + mi.migrationInfo + " SKIPPED" + runInfo);
140        }
141    }
142
143    private void updateVersionNumber(MigrationInfo mi) {
144        try {
145            logger.info("Updating installed model: version " + mi.migrationNr);
146            if (mi.migrationType.equals("core")) {
147                dmx.pl.storeMigrationNr(mi.migrationNr);
148            } else {
149                mi.plugin.setMigrationNr(mi.migrationNr);
150            }
151        } catch (Exception e) {
152            throw new RuntimeException("Updating the model version number to " + mi.migrationNr + " failed", e);
153        }
154    }
155
156    // ---
157
158    private void injectServices(Migration migration, String migrationInfo, PluginImpl plugin) {
159        try {
160            for (Field field : PluginImpl.getInjectableFields(migration.getClass())) {
161                Class<?> serviceInterface = field.getType();
162                Object service;
163                //
164                if (serviceInterface.getName().equals(plugin.getProvidedServiceInterface())) {
165                    // the migration consumes the plugin's own service
166                    service = plugin.getContext();
167                } else {
168                    service = plugin.getInjectedService(serviceInterface);
169                }
170                //
171                logger.info("Injecting service " + serviceInterface.getName() + " into " + migrationInfo);
172                field.set(migration, service);  // throws IllegalAccessException
173            }
174        } catch (Exception e) {
175            throw new RuntimeException("Injecting services into " + migrationInfo + " failed", e);
176        }
177    }
178
179    // ---
180
181    /**
182     * Creates types and topics from a JSON formatted input stream.
183     *
184     * @param   migrationFileName   The origin migration file. Used for logging only.
185     */
186    private void readMigrationFile(InputStream in, String migrationFileName) {
187        try {
188            logger.info("Reading migration file \"" + migrationFileName + "\"");
189            String fileContent = JavaUtils.readText(in);
190            //
191            Object value = new JSONTokener(fileContent).nextValue();
192            if (value instanceof JSONObject) {
193                readEntities((JSONObject) value);
194            } else if (value instanceof JSONArray) {
195                readEntities((JSONArray) value);
196            } else {
197                throw new RuntimeException("Invalid file content");
198            }
199        } catch (Exception e) {
200            throw new RuntimeException("Reading migration file \"" + migrationFileName + "\" failed", e);
201        }
202    }
203
204    private void readEntities(JSONArray entities) throws JSONException {
205        for (int i = 0; i < entities.length(); i++) {
206            readEntities(entities.getJSONObject(i));
207        }
208    }
209
210    private void readEntities(JSONObject entities) throws JSONException {
211        JSONArray topicTypes = entities.optJSONArray("topic_types");
212        if (topicTypes != null) {
213            createTopicTypes(topicTypes);
214        }
215        JSONArray assocTypes = entities.optJSONArray("assoc_types");
216        if (assocTypes != null) {
217            createAssociationTypes(assocTypes);
218        }
219        JSONArray topics = entities.optJSONArray("topics");
220        if (topics != null) {
221            createTopics(topics);
222        }
223        JSONArray assocs = entities.optJSONArray("associations");
224        if (assocs != null) {
225            createAssociations(assocs);
226        }
227    }
228
229    private void createTopicTypes(JSONArray topicTypes) throws JSONException {
230        for (int i = 0; i < topicTypes.length(); i++) {
231            dmx.createTopicType(mf.newTopicTypeModel(topicTypes.getJSONObject(i)));
232        }
233    }
234
235    private void createAssociationTypes(JSONArray assocTypes) throws JSONException {
236        for (int i = 0; i < assocTypes.length(); i++) {
237            dmx.createAssociationType(mf.newAssociationTypeModel(assocTypes.getJSONObject(i)));
238        }
239    }
240
241    private void createTopics(JSONArray topics) throws JSONException {
242        for (int i = 0; i < topics.length(); i++) {
243            dmx.createTopic(mf.newTopicModel(topics.getJSONObject(i)));
244        }
245    }
246
247    private void createAssociations(JSONArray assocs) throws JSONException {
248        for (int i = 0; i < assocs.length(); i++) {
249            dmx.createAssociation(mf.newAssociationModel(assocs.getJSONObject(i)));
250        }
251    }
252
253    // ------------------------------------------------------------------------------------------------- Private Classes
254
255    /**
256     * Collects the info required to run a migration.
257     */
258    private class MigrationInfo {
259
260        int migrationNr;
261        PluginImpl plugin;
262        //
263        String migrationType;       // "core", "plugin"
264        String migrationInfo;       // for logging
265        String runMode;             // "CLEAN_INSTALL", "UPDATE", "ALWAYS"
266        //
267        boolean isDeclarative;
268        boolean isImperative;
269        //
270        String migrationFile;       // for declarative migration
271        InputStream migrationIn;    // for declarative migration
272        //
273        String migrationClassName;  // for imperative migration
274        Class migrationClass;       // for imperative migration
275        //
276        boolean success;            // error occurred while construction?
277        Exception exception;        // the error
278
279        MigrationInfo(int migrationNr, PluginImpl plugin) {
280            try {
281                this.migrationNr = migrationNr;
282                this.plugin = plugin;
283                String configFile = migrationConfigFile();
284                InputStream configIn;
285                migrationFile = migrationFile();
286                migrationType = plugin != null ? "plugin" : "core";
287                //
288                if (migrationType.equals("core")) {
289                    migrationInfo = "core migration " + migrationNr;
290                    configIn     = getClass().getResourceAsStream(configFile);
291                    migrationIn  = getClass().getResourceAsStream(migrationFile);
292                    migrationClassName = coreMigrationClassName();
293                    migrationClass = loadClass(migrationClassName);
294                } else {
295                    migrationInfo = "migration " + migrationNr + " of " + plugin;
296                    configIn     = getStaticResourceOrNull(configFile);
297                    migrationIn  = getStaticResourceOrNull(migrationFile);
298                    migrationClassName = plugin.getMigrationClassName(migrationNr);
299                    if (migrationClassName != null) {
300                        migrationClass = plugin.loadClass(migrationClassName);
301                    }
302                }
303                //
304                isDeclarative = migrationIn != null;
305                isImperative = migrationClass != null;
306                //
307                readMigrationConfigFile(configIn, configFile);
308                //
309                success = true;
310            } catch (Exception e) {
311                exception = e;
312            }
313        }
314
315        private void checkValidity() {
316            if (!isDeclarative && !isImperative) {
317                String message = "Neither a migration file (" + migrationFile + ") nor a migration class ";
318                if (migrationClassName != null) {
319                    throw new RuntimeException(message + "(" + migrationClassName + ") is found");
320                } else {
321                    throw new RuntimeException(message + "is found. Note: a possible migration class can't be located" +
322                        " (plugin package is unknown). Consider setting \"dmx.plugin.main_package\" in " +
323                        "plugin.properties");
324                }
325            }
326            if (isDeclarative && isImperative) {
327                throw new RuntimeException("Ambiguity: a migration file (" + migrationFile + ") AND a migration " +
328                    "class (" + migrationClassName + ") are found. Consider using two different migration numbers.");
329            }
330        }
331
332        // ---
333
334        private void readMigrationConfigFile(InputStream in, String configFile) {
335            try {
336                Properties migrationConfig = new Properties();
337                if (in != null) {
338                    logger.info("Reading migration config file \"" + configFile + "\"");
339                    migrationConfig.load(in);
340                } else {
341                    logger.info("Reading migration config file \"" + configFile + "\" SKIPPED -- file does not exist");
342                }
343                //
344                runMode = migrationConfig.getProperty("migrationRunMode", MigrationRunMode.ALWAYS.name());
345                MigrationRunMode.valueOf(runMode);  // check if value is valid
346            } catch (IllegalArgumentException e) {
347                throw new RuntimeException("Reading migration config file \"" + configFile + "\" failed: \"" + runMode +
348                    "\" is an invalid value for \"migrationRunMode\"", e);
349            } catch (IOException e) {
350                throw new RuntimeException("Reading migration config file \"" + configFile + "\" failed", e);
351            }
352        }
353
354        // ---
355
356        private String migrationFile() {
357            return "/migrations/migration" + migrationNr + ".json";
358        }
359
360        private String migrationConfigFile() {
361            return "/migrations/migration" + migrationNr + ".properties";
362        }
363
364        private String coreMigrationClassName() {
365            return CORE_MIGRATIONS_PACKAGE + ".Migration" + migrationNr;
366        }
367
368        // --- Helper ---
369
370        private InputStream getStaticResourceOrNull(String resourceName) {
371            if (plugin.hasStaticResource(resourceName)) {
372                return plugin.getStaticResource(resourceName);
373            } else {
374                return null;
375            }
376        }
377
378        /**
379         * Uses the core bundle's class loader to load a class by name.
380         *
381         * @return  the class, or <code>null</code> if the class is not found.
382         */
383        private Class loadClass(String className) {
384            try {
385                return Class.forName(className);
386            } catch (ClassNotFoundException e) {
387                return null;
388            }
389        }
390    }
391}