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