com.tomtom.speedtools.mongodb.migratedb.MongoDBMigration.java Source code

Java tutorial

Introduction

Here is the source code for com.tomtom.speedtools.mongodb.migratedb.MongoDBMigration.java

Source

/*
 * Copyright (C) 2012-2016. TomTom International BV (http://tomtom.com).
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.tomtom.speedtools.mongodb.migratedb;

import com.google.common.collect.MapMaker;
import com.mongodb.DBCollection;
import com.mongodb.DBObject;
import com.tomtom.speedtools.mongodb.MongoDB;
import com.tomtom.speedtools.mongodb.MongoDBKeyNames;
import com.tomtom.speedtools.objects.Objects;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.*;

/**
 * This class defines a MongoDB migration from 1 version to another. By default, its migration path is the identity
 * transformation, which effectively only changes the database version number in the 'migrator' collection.
 */
public class MongoDBMigration {
    private static final Logger LOG = LoggerFactory.getLogger(MongoDBMigration.class);
    private static final Object NO_DEFAULT = new Object();
    public static final boolean NO_DUPLICATES = false;

    private final Map<DBObject, Context> contextMap = new MapMaker().weakKeys().makeMap();
    private final List<MongoDBMigrationProblem> problems = new ArrayList<>();
    private final Context rootContext = new Context(null, null, "");
    private boolean dryRun = false;
    private boolean databaseChanged = true;

    private final String fromVersion;
    private final String toVersion;

    private static final Comparator<Command> RANKING_COMPARATOR = new Comparator<Command>() {
        @Override
        public int compare(@Nonnull final Command o1, @Nonnull final Command o2) {
            assert o1 != null;
            assert o2 != null;

            return Integer.valueOf(o1.ranking()).compareTo(o2.ranking());
        }
    };

    /**
     * Define a MongoDBMigration.
     *
     * @param fromVersion 'From'-version (will be trimmed).
     * @param toVersion   'To'-version (will be trimmed).
     */
    public MongoDBMigration(@Nonnull final String fromVersion, @Nonnull final String toVersion) {
        assert fromVersion != null;
        assert toVersion != null;
        this.fromVersion = fromVersion.trim();
        this.toVersion = toVersion.trim();
    }

    @Nonnull
    protected List<MongoDBMigrationProblem> flush() {
        rootContext.flush();
        return problems;
    }

    /**
     * Used to modify top-level documents. Documents will be stored in the collection when modified.
     *
     * @param db             Database.
     * @param collectionName Collection to iterate over.
     * @return Iterable to loop over all documents.
     */
    @Nonnull
    protected Iterable<DBObject> migrateCollection(@Nonnull final MongoDB db,
            @Nonnull final String collectionName) {
        assert db != null;
        assert collectionName != null;

        rootContext.flush();
        final DBCollection collection = db.getCollection(collectionName);

        final long count = collection.count();
        if (count > Integer.MAX_VALUE) {
            addProblem("",
                    "Collection has too many records (" + count + ", where " + Integer.MAX_VALUE + " is max)");
        }

        /**
         * This set is going to to contain all records for sure, so make sure it is large enough not to get
         * re-allocated all the time.
         *
         * See HashMap's class description at [http://docs.oracle.com/javase/6/docs/api/java/util/HashMap.html],
         * specifically "The expected number of entries in the map and its load factor should be taken into account
         * when setting its initial capacity, so as to minimize the number of rehash operations. If the initial
         * capacity is greater than the maximum number of entries divided by the load factor, no rehash operations
         * will ever occur.".
         */
        @SuppressWarnings("NumericCastThatLosesPrecision")
        final Set<Object> recordIds = new HashSet<>((int) ((double) count / 0.75) + 1);

        return new IterableDelegate<DBObject, DBObject>(collection.find()) {

            private int index = 1;

            @Nullable
            @Override
            public DBObject next(@Nonnull final DBObject value) {

                final Context context = rootContext.createChild(value, collectionName + ':' + index);
                index++;

                // Each document should have an _id field.
                final Object id = value.get("_id");
                if (id == null) {
                    addProblem(context.path, "Document has no _id field: " + value);
                    return null;
                }

                // Don't process records we have already processed. This can happen if a record
                // is modified.
                if (recordIds.contains(id)) {
                    return null;
                }
                recordIds.add(id);

                // Keep original value in immutable string, referenced from 'flush()'.
                final String originalStringValue = value.toString();

                // Save object.
                context.add(new Command() {

                    @Override
                    public void flush() {

                        // If the new value differs from the old one, store it and print it.
                        final String stringValue = value.toString();
                        if (!originalStringValue.equals(stringValue)) {
                            if (!dryRun) {
                                collection.save(value);
                            }
                            LOG.debug(context.path + " - original document: " + originalStringValue);
                            LOG.debug(context.path + " - migrated document: " + value);
                        }
                    }

                    @Override
                    public int ranking() {
                        return Integer.MAX_VALUE; // Saves should be executed last.
                    }
                });

                return value;
            }
        };
    }

    /**
     * Used to add top-level documents.
     *
     * @param db             Database.
     * @param collectionName Collection to add to.
     * @param values         The values to add to the collection. Individual values must not be null.
     */
    protected void addToCollection(@Nonnull final MongoDB db, @Nonnull final String collectionName,
            @Nonnull final DBObject... values) {
        assert db != null;
        assert collectionName != null;
        assert values != null;

        rootContext.flush();
        final DBCollection collection = db.getCollection(collectionName);

        int index = 1;
        for (final DBObject value : values) {
            if (value != null) {
                final Context context = rootContext.createChild(value, collectionName + '+' + index);
                index++;

                // Each document should have an _id field.
                final Object id = value.get("_id");
                if (id == null) {
                    addProblem(context.path, "Document has no _id field: " + value);
                    break;
                }

                // Save object.
                context.add(new Command() {

                    @Override
                    public void flush() {
                        if (!dryRun) {
                            collection.save(value);
                        }
                        LOG.debug(context.path + " - added document: " + value);
                    }

                    @Override
                    public int ranking() {
                        return Integer.MAX_VALUE; // Saves should be executed last.
                    }
                });
            } else {
                addProblem(rootContext.path, "Trying to add null document.");
            }
        }
    }

    void setDryRun(final boolean dryRun) {
        this.dryRun = dryRun;
    }

    protected static abstract class Converter<T> {

        @Nullable
        public abstract T convert(@Nonnull Value value);

        @Nullable
        Object defaultValue() {
            return NO_DEFAULT;
        }
    }

    @Nonnull
    protected final Converter<Integer> convertToInt(final boolean optional) {
        return new Converter<Integer>() {

            @Nullable
            @Override
            public Integer convert(@Nonnull final Value value) {
                assert value != null;
                final Object valueValue = toSingle(value.value);
                if (valueValue != null) {
                    if (optional && valueValue.toString().trim().isEmpty()) {
                        return null;
                    }
                    try {
                        //noinspection unchecked
                        return Integer.parseInt(valueValue.toString());
                    } catch (final NumberFormatException ignored) {
                        addProblem(value.path,
                                "could not convert '" + valueValue + "' to an integer. Value " + "discarded.");
                    }
                }
                return null;
            }
        };
    }

    @Nonnull
    protected static Converter<Object> setValueConverter(@Nullable final Object value) {
        //noinspection ParameterNameDiffersFromOverriddenParameter
        return new Converter<Object>() {

            @Nullable
            @Override
            public Object convert(@Nonnull final Value ignored) {
                return value;
            }

            @Nullable
            @Override
            Nullable defaultValue() {
                // Can be any value other than NO_DEFAULT.
                return null;
            }
        };
    }

    @Nonnull
    protected final Converter<String> urlConverter = new Converter<String>() {

        @Nullable
        @Override
        public String convert(@Nonnull final Value value) {
            assert value != null;
            final Object valueValue = toSingle(value.value);
            if (valueValue != null) {
                try {
                    //noinspection unchecked
                    return new URL(valueValue.toString()).toString();
                } catch (final MalformedURLException ignored) {
                    addProblem(value.path, "Value '" + valueValue + "' is not a valid URL. " + "Value discarded.");
                }
            }
            return null;
        }
    };

    protected static <T> Converter<T> setDefaultConverter(@Nullable final T toValue) {
        return replaceValueConverter(null, null, toValue);
    }

    protected static <T> Converter<T> replaceValueConverter(@Nullable final T defaultValue,
            @Nullable final T fromValue, @Nullable final T toValue) {
        return new Converter<T>() {
            @Nullable
            @Override
            public T convert(@Nonnull final Value value) {
                if (Objects.equal(value.value, fromValue)) {
                    return toValue;
                }
                //noinspection unchecked
                return (T) value.value;
            }

            @Nullable
            @Override
            Object defaultValue() {
                return defaultValue;
            }
        };

    }

    /**
     * Reads all values from the property denoted by {@code fieldPaths}. Returns an {@link Iterable}, which may hold 0
     * or more {@link DBObject}s.
     *
     * @param object     The object to start evaluating the fieldPaths from.
     * @param fieldPaths The path components to the value that should be retrieved. A component must be a single
     *                   property name.
     * @return {@link Iterable} of type {@link DBObject} holding the retrieved value(s).
     */
    @Nonnull
    protected Iterable<DBObject> get(@Nonnull final DBObject object, @Nonnull final String... fieldPaths) {
        assert object != null;
        assert fieldPaths != null;

        final Context parentContext = contextMap.get(object);
        final Context context = parentContext.createChild(null, parentContext.path);

        final List<Value> values = getValues(new ArrayList<>(), object, parentContext.path, NO_DEFAULT, true,
                fieldPaths);
        return new IterableDelegate<Value, DBObject>(values) {
            private int index = 0;

            /**
             * Returns the {@link DBObject} at this position in the iterator.
             *
             * @param value The {@link Value} from which to return the {@link DBObject}.
             * @return {@link DBObject} or {@code null} if {@link Value} did not contain one.
             */
            @Override
            @Nullable
            public DBObject next(@Nonnull final Value value) {
                assert value != null;

                index++;
                context.createChild((DBObject) value.value,
                        value.path + ((values.size() > 1) ? (":" + index) : ""));

                // Should not forget to process.
                return (DBObject) value.value;
            }
        };
    }

    /**
     * Reads a single value from property denoted by the {@code fieldPath}. The value in the property must have a class
     * (derived from) {@code resultClass}. If not, a problem will be added, and null will be returned. If more than one
     * value is found, a problem will also be added, and null will be returned. If the path cannot be traversed (because
     * a property is missing), null is returned.
     *
     * @param resultClass The class the value should be assignable from.
     * @param object      The object to start evaluating the fieldPath from.
     * @param fieldPath   The path to the value that should be retrieved.
     * @param <T>         The expected type of the value.
     * @return The value, or null in case of errors.
     */
    @Nullable
    protected <T> T getSingleValue(@Nonnull final Class<T> resultClass, @Nonnull final DBObject object,
            @Nonnull final String fieldPath) {
        assert resultClass != null;
        assert object != null;
        assert fieldPath != null;

        final List<T> result = new ArrayList<>();
        final Iterable<T> values = getValues(resultClass, object, fieldPath);
        for (final T value : values) {
            if (value != null) {
                result.add(value);
            }
        }

        if (result.isEmpty()) {
            return null;
        } else if (result.size() == 1) {
            return result.get(0);
        } else {
            addProblem(fieldPath, "Multiple values found where a single value is expected");
            return null;
        }
    }

    /**
     * Reads values from one or more properties reachable from {@code object} by traversing the given {@code
     * fieldPaths}. These values should be assignable from the class {@code resultClass}. If a value is not, the value
     * is skipped, and a problem is added. If the path cannot be traversed (because a property is not available) it will
     * be skipped.
     *
     * @param resultClass The class the values should be assignable from.
     * @param object      The object to start evaluating the fieldPaths from.
     * @param fieldPaths  The paths to the values that should be retrieved.
     * @param <T>         The expected type of the value.
     * @return An iterable with values of type {@code T}.
     */
    @Nonnull
    protected <T> Iterable<T> getValues(@Nonnull final Class<T> resultClass, @Nonnull final DBObject object,
            @Nonnull final String... fieldPaths) {
        assert object != null;
        assert fieldPaths != null;
        assert resultClass != null;
        assert !DBObject.class.isAssignableFrom(resultClass) : "Use get(DBObject, String...) for DBObject results";

        final Context parentContext = contextMap.get(object);

        final List<Value> values = getValues(new ArrayList<>(), object, parentContext.path, NO_DEFAULT, false,
                fieldPaths);
        return new IterableDelegate<Value, T>(values) {

            @Override
            @Nullable
            public T next(@Nonnull final Value value) {
                assert value != null;

                // Check the type of the value.
                if ((value.value != null) && !resultClass.isAssignableFrom(value.value.getClass())) {
                    addProblem(value.path, "Expected type " + resultClass.getSimpleName());
                    return null;
                }

                // We either have null, or the right type.
                assert (value.value == null) || resultClass.isAssignableFrom(value.value.getClass());

                //noinspection unchecked
                return (T) value.value;
            }
        };
    }

    protected Iterable<Replaceable<DBObject, DBObject>> replace(@Nonnull final DBObject object,
            @Nonnull final String... fieldPaths) {
        return replace(object, DBObject.class, DBObject.class, fieldPaths);
    }

    protected <T, U> Iterable<Replaceable<T, U>> replace(@Nonnull final DBObject object,
            @Nonnull final Class<T> fieldType, @Nonnull final Class<U> newFieldType,
            @Nonnull final String... fieldPaths) {
        assert object != null;
        assert fieldType != null;
        assert newFieldType != null;
        assert fieldPaths != null;

        final Context parentContext = contextMap.get(object);
        final Context context = parentContext.createChild(null, parentContext.path);
        final List<Value> values = getValues(new ArrayList<>(), object, parentContext.path, NO_DEFAULT, false,
                fieldPaths);

        return new IterableDelegate<Value, Replaceable<T, U>>(values) {
            @Override
            public Replaceable<T, U> next(@Nonnull final Value value) {

                // Check source type.
                if (!fieldType.isInstance(value.value)) {
                    addProblem(value.path, "expected existing value of type " + fieldType.getSimpleName()
                            + ", but got: " + value.value);
                    return null;
                }
                context.createChild((DBObject) value.value, value.path);

                // Make sure empty elements are removed from the list.
                if (value.isSingleValue) {
                    value.parent.removeField(value.fieldName);
                } else {
                    context.add(new PruneList(value));
                }

                // Because of getValues(..., NO_DEFAULT, ...).
                final Object valueValue = value.value;
                assert valueValue != null;

                // Should not forget to process.
                //noinspection unchecked
                return new Replaceable<T, U>(context, (T) valueValue) {
                    @Override
                    public void set(@Nullable final U newValue) {
                        U newVal = newValue;
                        if ("".equals(newVal)) {
                            newVal = null;
                        }
                        if (value.isSingleValue) {
                            if (newVal != null) {
                                value.parent.put(value.fieldName, newVal);
                            } else {
                                value.parent.removeField(value.fieldName);
                            }
                        } else {
                            value.list.set(value.index, newVal);
                        }
                        logValueChanged(value, newVal);
                    }
                };
            }
        };
    }

    private static <U> void logValueChanged(@Nonnull final Value value, @Nullable final U newValue) {
        if (newValue == null) {
            if (value.value != null) {
                LOG.debug(value.path + " - removed value " + printValue(value.value));
            }
        } else {
            if (value.value == null) {
                LOG.debug(value.path + " - added value " + printValue(newValue));
            } else {
                if (!Objects.equal(value.value, newValue)) {
                    LOG.debug(value.path + " - replaced " + printValue(value.value) + " with "
                            + printValue(newValue));
                }
            }
        }
    }

    private static String printValue(@Nullable final Object value) {
        if (value instanceof String) {
            return "'" + value + '\'';
        }
        if (value == null) {
            return "null";
        }
        return value.toString();

    }

    protected void rename(@Nonnull final DBObject object, @Nonnull final String fieldPath,
            @Nonnull final String newFieldName) {
        assert object != null;
        assert fieldPath != null;
        assert newFieldName != null;
        final Context parentContext = contextMap.get(object);
        for (final Value value : getValues(new ArrayList<>(), object, parentContext.path, null, false, fieldPath)) {
            if (value.index == 0) {
                final Object v = value.parent.removeField(value.fieldName);
                if (v != null) {
                    value.parent.put(newFieldName, v);
                    LOG.debug(value.path + " - renamed to " + newFieldName);
                }
            }
        }
    }

    /**
     * Removes the field given by {@code fieldPath} from {@code object}.
     *
     * @param object    The object from which to delete the field.
     * @param fieldPath The path to the field to delete.
     */
    protected void remove(@Nonnull final DBObject object, @Nonnull final String fieldPath) {
        assert object != null;
        assert fieldPath != null;
        assert !fieldPath.isEmpty();
        final Context parentContext = contextMap.get(object);
        for (final Value value : getValues(new ArrayList<>(), object, parentContext.path, null, false, fieldPath)) {
            if (value.index == 0) {
                value.parent.removeField(value.fieldName);
                LOG.debug(value.path + " - removed");
            }
        }
    }

    /**
     * Sets the value at {@code fieldPath} to {@code toValue}. If the base path (i.e., the path before the last '.')
     * cannot be resolved, the value is not set. If any of the segments of the path refers to a collection field, all
     * objects contained are traversed, potentially resulting in multiple fields that will be set.
     *
     * @param object    The object to start evaluating the fieldPath from.
     * @param fieldPath The path to the value to set.
     * @param toValue   The value to set.
     */
    protected void setValue(@Nonnull final DBObject object, @Nonnull final String fieldPath,
            @Nullable final Object toValue) {
        convert(object, fieldPath, false, setValueConverter(toValue));
    }

    /**
     * Replaces the value {@code fromValue} at path {@code fieldPath} with {@code toValue}. If the path cannot be
     * resolved nothing is replaced. If any of the segments of the path refers to a collection field, all objects
     * contained are traversed, potentially resulting in multiple values that will be replaced.
     *
     * @param object    The object to start evaluating the fieldPath from.
     * @param fieldPath The path to the value to replace.
     * @param fromValue The value to replace.
     * @param toValue   The value to replace with.
     */
    protected void replaceValue(@Nonnull final DBObject object, @Nonnull final String fieldPath,
            @Nullable final Object fromValue, @Nullable final Object toValue) {
        convert(object, fieldPath, false, replaceValueConverter(NO_DEFAULT, fromValue, toValue));
    }

    /**
     * Sets the value {@code toValue} at path {@code fieldPath} when it does not already have a value. If the {@code
     * fieldPath} cannot be resolved, no changes are made. If any of the segments of the path refers to a collection
     * field, all objects contained are traversed, potentially resulting in multiple fields that will be set.
     *
     * @param object    The object to start evaluating the fieldPath from.
     * @param fieldPath The path to the value to set.
     * @param toValue   The value to set.
     */
    protected void setDefault(@Nonnull final DBObject object, @Nonnull final String fieldPath,
            @Nullable final Object toValue) {
        convert(object, fieldPath, false, setDefaultConverter(toValue));
    }

    /**
     * Convert the values referred to by (object, fieldPath) using the given {@link Converter}.
     *
     * @param object             The object to start evaluating the fieldPath from.
     * @param fieldPath          The path to the values to convert.
     * @param createChildContext If true, the created {@link Value}s will be added to the parent context. The
     *                           consequence of this is that the converter is then able to use MongoDBMigration-methods
     *                           that are dependent on this context (any of the getters that work with Values
     *                           internally, such as {@link #getSingleValue(Class, DBObject, String)}).
     * @param converter          The converter to convert the values with.
     */
    protected void convert(@Nonnull final DBObject object, @Nonnull final String fieldPath,
            final boolean createChildContext, @Nonnull final Converter<?> converter) {
        assert object != null;
        assert fieldPath != null;
        assert converter != null;

        final Context parentContext = contextMap.get(object);
        final List<Value> values = getValues(new ArrayList<>(), object, parentContext.path,
                converter.defaultValue(), false, fieldPath);
        for (final Value value : values) {

            if (createChildContext) {
                parentContext.createChild((DBObject) value.value, value.path);
            }

            Object newValue = converter.convert(value);
            if ("".equals(newValue)) {
                newValue = null;
            }
            if (value.isSingleValue) {
                if (newValue != null) {
                    value.parent.put(value.fieldName, newValue);
                } else {
                    value.parent.removeField(value.fieldName);
                }
            } else {
                // If a default value was added in getValues(), the value list can be empty.
                if (value.list.isEmpty()) {
                    value.list.add(newValue);
                } else {
                    value.list.set(value.index, newValue);
                }

                // Always prune the values list.
                parentContext.add(new PruneList(value), NO_DUPLICATES);
            }
            logValueChanged(value, newValue);
        }
    }

    @Nonnull
    @SuppressWarnings("ConstantConditions")
    private List<Value> getValues(@Nonnull final List<Value> result, @Nonnull final DBObject object,
            @Nonnull final String currentPath, @Nullable final Object defaultValue, final boolean needObjects,
            @Nonnull final String... fieldPaths) {
        assert result != null;
        assert object != null;
        assert currentPath != null;
        assert fieldPaths != null;

        for (final String commaSeparatedFieldPath : fieldPaths) {
            for (final String unTrimmedFieldPath : commaSeparatedFieldPath.split(",")) {
                final String fieldPath = unTrimmedFieldPath.trim();
                final String typedFieldName;
                final String remainingPath;
                final int dotIndex = fieldPath.indexOf('.');
                if (dotIndex >= 0) {
                    typedFieldName = fieldPath.substring(0, dotIndex).trim();
                    remainingPath = fieldPath.substring(dotIndex + 1).trim();
                } else {
                    typedFieldName = fieldPath.trim();
                    remainingPath = null;
                }
                final String fieldName;
                final String discriminator;
                final int colonIndex = typedFieldName.indexOf(':');
                if (colonIndex >= 0) {
                    fieldName = typedFieldName.substring(0, colonIndex).trim();
                    discriminator = typedFieldName.substring(colonIndex + 1).trim();
                } else {
                    fieldName = typedFieldName;
                    discriminator = null;
                }
                final Object value;
                // check top-level discriminator
                if (fieldName.isEmpty() && !discriminator.isEmpty()) {
                    value = object;
                } else {
                    value = object.get(fieldName);
                }
                int index = 0;
                final boolean shouldRecurse = (remainingPath != null);
                final List<Object> list = toList(value);
                for (final Object elt : list) {
                    final String path = currentPath + '.' + fieldName
                            + ((list.size() > 1) ? (":" + (index + 1)) : "");
                    if (elt instanceof DBObject) {
                        final DBObject objval = ((DBObject) elt);
                        if ((discriminator == null)
                                || discriminator.equals(objval.get(MongoDBKeyNames.DISCRIMINATOR_KEY))) {
                            if (shouldRecurse) {
                                // Recurse.
                                getValues(result, objval, path, defaultValue, needObjects, remainingPath);
                            }
                        }
                    } else {
                        if (shouldRecurse || (discriminator != null) || needObjects) {
                            addProblem(path, "not an object");
                        }
                    }
                    if (!shouldRecurse) {
                        final Value v = new Value(currentPath + '.' + fieldName, object, fieldName, list, index,
                                !(value instanceof List), elt);
                        result.add(v);
                    }
                    index++;
                }
                if ((remainingPath == null) && list.isEmpty() && !NO_DEFAULT.equals(defaultValue)) {
                    result.add(new Value(currentPath + '.' + fieldName, object, fieldName, list, index,
                            !(value instanceof List), defaultValue));
                }
            }
        }
        return result;
    }

    protected static class Value {
        @Nonnull
        public final String path;
        @Nonnull
        public final DBObject parent;
        @Nonnull
        public final String fieldName;
        @Nonnull
        public final List<Object> list;
        public final int index;
        @Nullable
        public final Object value;
        public final boolean isSingleValue;

        private Value(final @Nonnull String path, final @Nonnull DBObject parent, final @Nonnull String fieldName,
                final @Nonnull List<Object> list, final int index, final boolean isSingleValue,
                @Nullable final Object value) {
            assert path != null;
            assert parent != null;
            assert fieldName != null;
            assert list != null;
            this.path = path;
            this.parent = parent;
            this.fieldName = fieldName;
            this.list = list;
            this.index = index;
            this.value = value;
            this.isSingleValue = isSingleValue;
        }
    }

    @Nonnull
    private static List<Object> toList(@Nullable final Object value) {
        if (value instanceof List) {
            //noinspection unchecked
            return (List<Object>) value;
        }
        if (value == null) {
            return Collections.emptyList();
        }
        return Collections.singletonList(value);
    }

    @Nullable
    protected static Object toSingle(@Nullable final Object value) {
        if (value instanceof List) {
            if (((List) value).isEmpty()) {
                return null;
            }
            return ((List) value).get(0);
        }
        return value;
    }

    @Nonnull
    protected static String getFirstString(@Nullable final Object value) {
        final Object single = toSingle(value);
        if (single != null) {
            return single.toString();
        }
        return "";
    }

    @Nullable
    protected Integer parsePositiveInt(@Nonnull final String value, @Nullable final Integer defaultValue) {
        assert value != null;
        int firstDigit = -1;
        int lastDigit = -1;
        for (int i = 0; i < value.length(); i++) {
            if (Character.isDigit(value.charAt(i))) {
                if (firstDigit == -1) {
                    firstDigit = i;
                }
                lastDigit = i;
            } else if (firstDigit != -1) {
                break;
            }
        }
        if (firstDigit == -1) {
            return defaultValue;
        }
        return Integer.parseInt(value.substring(firstDigit, lastDigit + 1).trim());
    }

    @Nonnull
    protected String parsePositiveIntSuffix(@Nonnull final String value) {
        assert value != null;
        boolean digitFound = false;
        for (int i = 0; i < value.length(); i++) {
            final char c = value.charAt(i);
            if ((c != ' ') && (c != '-')) {
                if (Character.isDigit(c)) {
                    digitFound = true;
                } else if (digitFound) {
                    return value.substring(i).trim();
                }
            }
        }
        return "";
    }

    protected void addProblem(@Nonnull final String path, @Nonnull final String problem) {
        assert problem != null;
        assert path != null;
        LOG.error("{} - {}", path, problem);
        problems.add(new MongoDBMigrationProblem(problem, path));
    }

    /**
     * Return 'from'-version.
     *
     * @return Trimmed version string.
     */
    @Nonnull
    protected String getFromVersion() {
        return fromVersion;
    }

    /**
     * Return 'to'-version.
     *
     * @return Trimmed version string.
     */
    @Nonnull
    protected String getToVersion() {
        return toVersion;
    }

    /**
     * Default migration: no-op.
     *
     * @param db MongoDB database.
     * @throws MigrationException Never thrown by this class, but potentially by derived classes, if the migration
     *                            fails.
     */
    protected void migrate(@Nonnull final MongoDB db) throws MigrationException {
        assert db != null;

        // No upgrade required.
        LOG.info("migrate: no upgrade required");

        // Only this method can set databaseChange to false. Derived classes cannot.
        databaseChanged = false;
    }

    /**
     * Execute migration and report if database was changed or not.
     *
     * @param db MongoDB database.
     * @return True if database changed as a result of migration.
     * @throws MigrationException Never thrown by this class, but potentially by derived classes, if the migration
     *                            fails.
     */
    boolean migrateChangedDatabase(@Nonnull final MongoDB db) throws MigrationException {
        assert db != null;

        migrate(db);
        return databaseChanged;
    }

    protected abstract static class Replaceable<T, U> {

        @Nonnull
        private final Context context;
        @Nonnull
        private final T value;

        private Replaceable(@Nonnull final Context context, @Nonnull final T value) {
            assert context != null;
            assert value != null;
            this.context = context;
            this.value = value;
        }

        @Nonnull
        public T get() {
            return value;
        }

        @Nonnull
        public Context getContext() {
            return context;
        }

        public abstract void set(@Nullable final U newValue);
    }

    protected interface Command {
        public void flush();

        /**
         * Ranking for executing the command. Lower ranked commands are executed first.
         *
         * @return The ranking for the command.
         */
        public int ranking();
    }

    private static class PruneList implements Command {

        @Nonnull
        private final Value value;

        private PruneList(@Nonnull final Value value) {
            assert value != null;
            this.value = value;
        }

        @Override
        public void flush() {
            for (final Iterator<Object> it = value.list.iterator(); it.hasNext();) {
                if (it.next() == null) {
                    it.remove();
                }
            }
            if (value.list.isEmpty()) {
                value.parent.removeField(value.fieldName);
            }
        }

        @Override
        public int ranking() {
            return 0;
        }

        @Override
        public boolean equals(final Object obj) {
            if (this == obj) {
                return true;
            }
            if ((obj == null) || (getClass() != obj.getClass())) {
                return false;
            }

            final PruneList pruneList = (PruneList) obj;

            if (!value.list.equals(pruneList.value.list)) {
                return false;
            }

            return true;
        }

        @Override
        public int hashCode() {
            return value.list.hashCode();
        }
    }

    @SuppressWarnings("ThisEscapedInObjectConstruction")
    protected class Context {
        @Nullable
        private final Context parent;
        @Nullable
        private Context child;
        @Nullable
        private final DBObject object;
        @Nullable
        private PriorityQueue<Command> commands;
        @Nonnull
        private final String path;

        public Context(@Nullable final Context parent, @Nullable final DBObject object,
                @Nonnull final String path) {
            assert path != null;
            this.parent = parent;
            this.object = object;
            this.path = path;
            if (object != null) {
                contextMap.put(object, this);
            }
        }

        @Nonnull
        public Context createChild(@Nullable final DBObject object, @Nonnull final String path) {
            assert path != null;
            flushChild();
            child = new Context(this, object, path);
            return child;
        }

        public void addProblem(@Nonnull final String problem) {
            assert problem != null;
            LOG.error(path + ": " + problem);
            problems.add(new MongoDBMigrationProblem(problem, path));
        }

        public void assertValid() {
            //noinspection ObjectEquality
            assert (parent == null) || (parent.child == this) : "Context no longer valid.";
        }

        /**
         * Convenience method for add(Command, boolean) with {@code true} as its second parameter.
         *
         * @param command The command to add.
         */
        public void add(@Nonnull final Command command) {
            add(command, true);
        }

        /**
         * Add a command to the context.
         *
         * @param command      The command to add.
         * @param addIfPresent If true, the command is added even if the command was already added before. If false, the
         *                     command will only be added if not already present.
         */
        public void add(@Nonnull final Command command, final boolean addIfPresent) {
            assert command != null;
            assertValid();
            if (commands == null) {
                commands = new PriorityQueue<>(1, RANKING_COMPARATOR);
            }
            if (addIfPresent || !commands.contains(command)) {
                commands.add(command);
            }
        }

        public void flush() {
            assertValid();
            flushChild();
            if (commands != null) {
                for (Command command = commands.poll(); command != null; command = commands.poll()) {
                    command.flush();
                }
            }
        }

        private void flushChild() {
            if (child != null) {
                child.flush();
                // Child should no longer be used.
                if (child.object != null) {
                    contextMap.remove(child.object);
                }
                child = null;
            }
        }
    }

    protected abstract static class IterableDelegate<T, U> implements Iterable<U> {
        @Nonnull
        final private Iterable<T> delegate;

        protected IterableDelegate(@Nonnull final Iterable<T> delegate) {
            this.delegate = delegate;
        }

        @Nullable
        protected abstract U next(T value);

        protected void finished() {
            // Empty.
        }

        @Override
        public Iterator<U> iterator() {
            final Iterator<T> iterator = delegate.iterator();
            return new Iterator<U>() {
                private U next;
                private boolean finished = false;

                @Override
                public boolean hasNext() {
                    while ((next == null) && iterator.hasNext()) {
                        next = IterableDelegate.this.next(iterator.next());
                    }
                    if (next != null) {
                        return true;
                    }
                    if (!finished) {
                        finished = true;
                        finished();
                    }
                    return false;
                }

                @Override
                public U next() throws NoSuchElementException {
                    if (next == null) {
                        throw new NoSuchElementException();
                    }
                    final U result = next;
                    next = null;
                    return result;
                }

                @Override
                public void remove() {
                    iterator.remove();
                }
            };
        }
    }
}