com.getbase.android.schema.Schemas.java Source code

Java tutorial

Introduction

Here is the source code for com.getbase.android.schema.Schemas.java

Source

/*
 * Copyright (C) 2013 Jerzy Chalupski
 *
 * 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.getbase.android.schema;

import com.google.common.base.Function;
import com.google.common.base.Joiner;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.base.Supplier;
import com.google.common.base.Suppliers;
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.collect.Sets.SetView;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;
import android.util.Log;

import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;

public class Schemas {
    private static final String TAG = Schemas.class.getSimpleName();
    private static final Release INITIAL_DB_SCHEMA = new Release() {
        @Override
        public int getSchemaVersion() {
            return 0;
        }

        @Override
        public String toString() {
            return "INITIAL DB SCHEMA";
        }
    };

    private final ImmutableMap<Integer, Migration[]> mMigrations;
    private final ImmutableMap<Integer, ImmutableMap<String, ImmutableList<? extends TableDowngradeOperation>>> mDowngrades;
    private final LoadingCache<Integer, ImmutableMap<String, ImmutableList<? extends TableDefinitionOperation>>> mRevisions = CacheBuilder
            .newBuilder()
            .build(new CacheLoader<Integer, ImmutableMap<String, ImmutableList<? extends TableDefinitionOperation>>>() {
                @Override
                public ImmutableMap<String, ImmutableList<? extends TableDefinitionOperation>> load(Integer key)
                        throws Exception {
                    Integer lastMergedRevision = Collections.min(mRevisions.asMap().keySet());
                    Log.d(TAG,
                            "Building migration to " + key + " (min schema prepared: " + lastMergedRevision + ")");
                    Preconditions.checkState(lastMergedRevision > key,
                            "Trying to retrieve version %s, which is higher than current schema version", key);
                    ImmutableMap<String, ImmutableList<? extends TableDefinitionOperation>> schema = mRevisions
                            .getIfPresent(lastMergedRevision);
                    Preconditions.checkState(schema != null);

                    for (int revision = lastMergedRevision - 1;; --revision) {
                        Log.d(TAG, "Prepare schema for " + revision);
                        if (mDowngrades.containsKey(revision)) {
                            schema = merge(schema, mDowngrades.get(revision), revision);
                        }

                        if (revision == key) {
                            return schema;
                        } else {
                            mRevisions.put(revision, schema);
                        }
                    }
                }
            });
    private final ImmutableList<Release> mReleases;

    private final Function<Integer, String> mRevisionDescriptionBuilder = new Function<Integer, String>() {
        @Override
        public String apply(final Integer revision) {
            Release release = Iterables.find(mReleases, new Predicate<Release>() {
                @Override
                public boolean apply(Release r) {
                    return r.getSchemaVersion() <= revision;
                }
            });

            int downgradeOffset = revision - release.getSchemaVersion();
            return "revision " + revision + " (downgradeTo(" + downgradeOffset + ", ...) above " + release + ")";
        }
    };

    private ImmutableMap<String, ImmutableList<? extends TableDefinitionOperation>> merge(
            ImmutableMap<String, ImmutableList<? extends TableDefinitionOperation>> schema,
            ImmutableMap<String, ImmutableList<? extends TableDowngradeOperation>> downgrades, int targetRevision) {
        ImmutableMap.Builder<String, ImmutableList<? extends TableDefinitionOperation>> builder = ImmutableMap
                .builder();

        for (String unchangedTable : Sets.difference(schema.keySet(), downgrades.keySet())) {
            builder.put(unchangedTable, schema.get(unchangedTable));
        }

        for (String alteredTable : Sets.intersection(downgrades.keySet(), schema.keySet())) {
            ImmutableList<? extends TableDefinitionOperation> mergedOperations = MERGER.merge(
                    schema.get(alteredTable), downgrades.get(alteredTable), alteredTable, targetRevision,
                    mRevisionDescriptionBuilder);
            if (!mergedOperations.isEmpty()) {
                builder.put(alteredTable, mergedOperations);
            }
        }

        for (String addedTable : Sets.difference(downgrades.keySet(), schema.keySet())) {
            builder.put(addedTable, CONVERTER.convert(downgrades.get(addedTable), addedTable, targetRevision,
                    mRevisionDescriptionBuilder));
        }

        return builder.build();
    }

    private static final DowngradeToDefinitionConverter CONVERTER = new DowngradeToDefinitionConverter();

    private static class DowngradeToDefinitionConverter implements TableOperationVisitor {

        private ImmutableList.Builder<TableDefinitionOperation> builder;
        private String mTable;
        private int mTargetRevision;
        private Function<Integer, String> mRevisionDescriptionBuilder;

        public ImmutableList<? extends TableDefinitionOperation> convert(
                ImmutableList<? extends TableDowngradeOperation> downgrades, String table, int targetRevision,
                Function<Integer, String> revisionDescriptionBuilder) {
            mTable = table;
            mTargetRevision = targetRevision;
            mRevisionDescriptionBuilder = revisionDescriptionBuilder;
            builder = ImmutableList.builder();

            for (TableDowngradeOperation downgrade : downgrades) {
                downgrade.accept(this);
            }

            return builder.build();
        }

        @Override
        public void visit(AddColumn addColumn) {
            builder.add(addColumn);
        }

        @Override
        public void visit(DropColumn dropColumn) {
            throw new IllegalStateException();
        }

        @Override
        public void visit(DropTable dropTable) {
            throw new IllegalStateException("Trying to drop non existing table " + mTable + " while building "
                    + mRevisionDescriptionBuilder.apply(mTargetRevision));
        }

        @Override
        public void visit(DropConstraint dropConstraint) {
            throw new IllegalStateException();
        }

        @Override
        public void visit(AddConstraint addConstraint) {
            builder.add(addConstraint);
        }
    }

    private static final TableOperationMerger MERGER = new TableOperationMerger();

    private static class TableOperationMerger implements TableOperationVisitor {
        private String mTable;
        private int mTargetRevision;
        private Function<Integer, String> mRevisionDescriptionBuilder;
        private Map<TableOperationId, TableDefinitionOperation> mMergedOperations;

        public ImmutableList<? extends TableDefinitionOperation> merge(
                ImmutableList<? extends TableDefinitionOperation> schema,
                ImmutableList<? extends TableDowngradeOperation> downgrades, String table, int targetRevision,
                Function<Integer, String> revisionDescriptionBuilder) {
            mTable = table;
            mTargetRevision = targetRevision;
            mRevisionDescriptionBuilder = revisionDescriptionBuilder;
            mMergedOperations = Maps.newHashMap();

            for (TableOperation operation : schema) {
                operation.accept(this);
            }

            for (TableOperation operation : downgrades) {
                operation.accept(this);
            }

            return ImmutableList.copyOf(mMergedOperations.values());
        }

        @Override
        public void visit(AddColumn addColumn) {
            mMergedOperations.put(addColumn.getId(), addColumn);
        }

        @Override
        public void visit(DropColumn dropColumn) {
            TableDefinitionOperation droppedColumn = mMergedOperations.remove(dropColumn.getId());
            Preconditions.checkState(droppedColumn != null,
                    "Trying to drop non existing column %s.%s while building %s", mTable, dropColumn.mColumnName,
                    mRevisionDescriptionBuilder.apply(mTargetRevision));
        }

        @Override
        public void visit(DropTable dropTable) {
            mMergedOperations.clear();
        }

        @Override
        public void visit(DropConstraint dropConstraint) {
            TableDefinitionOperation droppedConstraint = mMergedOperations.remove(dropConstraint.getId());
            Preconditions.checkState(droppedConstraint != null,
                    "Trying to drop non existing constraint '%s' on table %s while building %s",
                    dropConstraint.mConstraintDefinition, mTable,
                    mRevisionDescriptionBuilder.apply(mTargetRevision));
        }

        @Override
        public void visit(AddConstraint addConstraint) {
            mMergedOperations.put(addConstraint.getId(), addConstraint);
        }
    }

    private Schemas(int currentRevision, Map<String, ImmutableList<? extends TableDefinitionOperation>> tables,
            ImmutableMap<Integer, ImmutableMap<String, ImmutableList<? extends TableDowngradeOperation>>> downgrades,
            ImmutableMap<Integer, Migration[]> migrations, ImmutableList<Release> releases) {
        mRevisions.put(currentRevision, ImmutableMap.copyOf(tables));
        mDowngrades = downgrades;
        mMigrations = migrations;
        mReleases = releases;
    }

    public Schema getSchema(int version) {
        return new Schema(version);
    }

    public Schema getCurrentSchema() {
        return getSchema(getCurrentRevisionNumber());
    }

    public ImmutableSet<String> getTablesModifiedInRevision(int version) {
        return mDowngrades.containsKey(version - 1) ? mDowngrades.get(version - 1).keySet()
                : ImmutableSet.<String>of();
    }

    public class Schema {
        private final ImmutableMap<String, ImmutableList<? extends TableDefinitionOperation>> mTableDefinitions;
        private final int mVersion;

        private Schema(int version) {
            mVersion = version;
            mTableDefinitions = mRevisions.getUnchecked(version);
        }

        public String getCreateTableStatement(String tableName) {
            Preconditions.checkArgument(mTableDefinitions.containsKey(tableName),
                    "Schema for version %s doesn't contain table %s", mVersion, tableName);
            return CREATE_STATEMENT_BUILDER.build(tableName, mTableDefinitions.get(tableName));
        }

        public ImmutableSet<String> getTables() {
            return mTableDefinitions.keySet();
        }

        public ImmutableSet<String> getColumns(String table) {
            return COLUMNS_GETTER.getColumns(mTableDefinitions.get(table));
        }
    }

    private static final ColumnsGetter COLUMNS_GETTER = new ColumnsGetter();

    public static class ColumnsGetter implements TableOperationVisitor {

        ImmutableSet.Builder<String> mBuilder;

        public ImmutableSet<String> getColumns(Iterable<? extends TableDefinitionOperation> operations) {
            mBuilder = ImmutableSet.builder();

            for (TableDefinitionOperation operation : operations) {
                operation.accept(this);
            }

            return mBuilder.build();
        }

        @Override
        public void visit(AddColumn addColumn) {
            mBuilder.add(addColumn.mColumnName);
        }

        @Override
        public void visit(DropColumn dropColumn) {
            throw new IllegalStateException();
        }

        @Override
        public void visit(DropTable dropTable) {
            throw new IllegalStateException();
        }

        @Override
        public void visit(DropConstraint dropConstraint) {
            throw new IllegalStateException();
        }

        @Override
        public void visit(AddConstraint addConstraint) {
            // ignore
        }
    }

    private final Supplier<Integer> mCurrentRevision = Suppliers.memoize(new Supplier<Integer>() {
        @Override
        public Integer get() {
            return Collections.max(mRevisions.asMap().keySet());
        }
    });

    public int getCurrentRevisionNumber() {
        return mCurrentRevision.get();
    }

    private static final TableCreateStatementBuilder CREATE_STATEMENT_BUILDER = new TableCreateStatementBuilder();

    private static class TableCreateStatementBuilder implements TableOperationVisitor {
        private List<String> mColumns;
        private List<String> mConstraints;

        public String build(String tableName, ImmutableList<? extends TableDefinitionOperation> operations) {
            mColumns = Lists.newArrayList();
            mConstraints = Lists.newArrayList();

            for (TableOperation operation : operations) {
                operation.accept(this);
            }

            List<String> parts = Lists.newArrayList();
            parts.addAll(mColumns);
            parts.addAll(mConstraints);

            return "CREATE TABLE " + tableName + "(" + Joiner.on(", ").join(parts) + ")";
        }

        @Override
        public void visit(AddColumn addColumn) {
            mColumns.add(addColumn.mColumnName + " " + addColumn.mColumnDefinition);
        }

        @Override
        public void visit(DropColumn dropColumn) {
            throw new IllegalStateException("Received DropColumn operation for building create statement");
        }

        @Override
        public void visit(DropTable dropTable) {
            throw new IllegalStateException("Received DropTable operation for building create statement");
        }

        @Override
        public void visit(DropConstraint dropConstraint) {
            throw new IllegalStateException("Received DropConstraint operation for building create statement");
        }

        @Override
        public void visit(AddConstraint addConstraint) {
            mConstraints.add(addConstraint.mConstraintDefinition);
        }
    }

    private enum OperationScope {
        Table, Column, TableConstraint
    }

    interface TableOperationVisitor {
        void visit(AddColumn addColumn);

        void visit(DropColumn dropColumn);

        void visit(DropTable dropTable);

        void visit(DropConstraint dropConstraint);

        void visit(AddConstraint addConstraint);
    }

    public interface TableOperation {
        TableOperationId getId();

        void accept(TableOperationVisitor visitor);
    }

    private static class TableOperationId {
        private final OperationScope mScope;
        private final String mName;

        public TableOperationId(OperationScope scope, String name) {
            mScope = Preconditions.checkNotNull(scope);
            mName = Preconditions.checkNotNull(name);
        }

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

            TableOperationId that = (TableOperationId) o;

            return Objects.equal(mScope, that.mScope) && Objects.equal(mName, that.mName);
        }

        @Override
        public int hashCode() {
            return Objects.hashCode(mScope, mName);
        }
    }

    public interface TableDowngradeOperation extends TableOperation {
    }

    public interface TableDefinitionOperation extends TableOperation {
    }

    public static class AddColumn implements TableDefinitionOperation, TableDowngradeOperation {
        private final String mColumnName;
        private final String mColumnDefinition;
        private final TableOperationId mId;

        public AddColumn(String columnName, String columnDefinition) {
            mColumnName = columnName;
            mColumnDefinition = columnDefinition;
            mId = new TableOperationId(OperationScope.Column, columnName);
        }

        @Override
        public TableOperationId getId() {
            return mId;
        }

        @Override
        public void accept(TableOperationVisitor visitor) {
            visitor.visit(this);
        }
    }

    public static class DropColumn implements TableDowngradeOperation {
        public final String mColumnName;
        private final TableOperationId mId;

        public DropColumn(String columnName) {
            mColumnName = columnName;
            mId = new TableOperationId(OperationScope.Column, columnName);
        }

        @Override
        public TableOperationId getId() {
            return mId;
        }

        @Override
        public void accept(TableOperationVisitor visitor) {
            visitor.visit(this);
        }
    }

    public static class DropTable implements TableDowngradeOperation {
        static final TableOperationId DROP_TABLE_OPERATION_ID = new TableOperationId(OperationScope.Table, "");

        @Override
        public TableOperationId getId() {
            return DROP_TABLE_OPERATION_ID;
        }

        @Override
        public void accept(TableOperationVisitor visitor) {
            visitor.visit(this);
        }
    }

    public static class AddConstraint implements TableDefinitionOperation, TableDowngradeOperation {
        private final String mConstraintDefinition;
        private final TableOperationId mId;

        public AddConstraint(String constraintDefinition) {
            mConstraintDefinition = constraintDefinition;
            mId = new TableOperationId(OperationScope.TableConstraint, constraintDefinition);
        }

        @Override
        public TableOperationId getId() {
            return mId;
        }

        @Override
        public void accept(TableOperationVisitor visitor) {
            visitor.visit(this);
        }
    }

    public static class DropConstraint implements TableDowngradeOperation {
        public final String mConstraintDefinition;
        private final TableOperationId mId;

        public DropConstraint(String constraintDefinition) {
            mConstraintDefinition = constraintDefinition;
            mId = new TableOperationId(OperationScope.TableConstraint, constraintDefinition);
        }

        @Override
        public TableOperationId getId() {
            return mId;
        }

        @Override
        public void accept(TableOperationVisitor visitor) {
            visitor.visit(this);
        }
    }

    public static class TableDefinition extends SchemaPart<TableDefinitionOperation> {
        public TableDefinition(String tableName, TableDefinitionOperation... operations) {
            super(tableName, operations);
        }

        public TableDefinition(String tableName, ImmutableList<TableDefinitionOperation> operations) {
            super(tableName, operations);
        }
    }

    public static TableDowngrade dropTable(String tableName) {
        return new TableDowngrade(tableName, new DropTable());
    }

    public static class TableDowngrade extends SchemaPart<TableDowngradeOperation> {
        public TableDowngrade(String tableName, TableDowngradeOperation... operations) {
            super(tableName, operations);
        }

        public TableDowngrade(String tableName, ImmutableList<? extends TableDowngradeOperation> operations) {
            super(tableName, operations);
        }
    }

    private static class SchemaPart<T extends TableOperation> {
        protected final String mTableName;
        protected final ImmutableList<? extends T> mOperations;

        private SchemaPart(String tableName, T... operations) {
            this(tableName, ImmutableList.copyOf(operations));
        }

        private SchemaPart(String tableName, ImmutableList<? extends T> operations) {
            validateTableOperations(tableName, operations);

            mTableName = tableName;
            mOperations = operations;
        }
    }

    private static void validateTableOperations(String tableName,
            ImmutableList<? extends TableOperation> operations) {
        Preconditions.checkArgument(!operations.isEmpty(), "Schema part should contain at least one operation");

        List<TableOperationId> ids = Lists.newArrayList();
        for (TableOperation operation : operations) {
            ids.add(operation.getId());
        }

        if (ids.contains(DropTable.DROP_TABLE_OPERATION_ID)) {
            Preconditions.checkArgument(operations.size() == 1,
                    "DropTable operation in downgrade definition for table " + tableName
                            + " cannot be mixed with other operations");
        }
        Preconditions.checkArgument(Sets.newHashSet(ids).size() == ids.size(),
                "Duplicate operations on single column or constraint in " + tableName);
    }

    public static class Builder {
        private final int mCurrentRevisionOffset;
        private final Map<String, ImmutableList<? extends TableDefinitionOperation>> mTables = Maps.newHashMap();
        private final ImmutableMap.Builder<Integer, ImmutableMap<String, ImmutableList<? extends TableDowngradeOperation>>> mDowngradesBuilder = ImmutableMap
                .builder();
        private final ImmutableMap.Builder<Integer, Migration[]> mMigrationsBuilder = ImmutableMap.builder();
        private final ImmutableList.Builder<Release> mReleasesBuilder = ImmutableList.builder();

        private Integer mCurrentOffset;
        private boolean mUpgradeToCurrentOffsetDefined;

        private Release mLastRelease;

        private Release mCurrentRelease;
        private final Map<Integer, ImmutableMap<String, ImmutableList<? extends TableDowngradeOperation>>> mPendingDowngrades = Maps
                .newHashMap();
        private final Map<Integer, Migration[]> mPendingMigrations = Maps.newHashMap();

        private Builder(int offset, TableDefinition[] tables) {
            for (TableDefinition table : tables) {
                Preconditions.checkArgument(mTables.put(table.mTableName, table.mOperations) == null,
                        "Duplicate table " + table.mTableName + " in current schema");
            }

            mCurrentRevisionOffset = offset;
            mCurrentOffset = offset;
            mUpgradeToCurrentOffsetDefined = false;
        }

        public static OldSchemasBuilder currentSchema(int revision, TableDefinition... tables) {
            return new Builder(revision, tables).new OldSchemasBuilder();
        }

        public class OldSchemasBuilder {
            public OldSchemasBuilder downgradeTo(int offset, TableDowngrade... tableDowngrades) {
                Preconditions.checkArgument(offset >= 0, "Downgrade offset cannot be negative");
                Preconditions.checkArgument(mCurrentOffset == null || offset < mCurrentOffset,
                        "Downgrades and upgrades definitions should have descending offsets. The downgrade offset (%s) should be lower than current offset (%s)",
                        offset, mCurrentOffset);

                Map<String, ImmutableList<? extends TableDowngradeOperation>> downgrades = Maps.newHashMap();
                for (TableDowngrade tableDowngrade : tableDowngrades) {
                    Preconditions.checkArgument(
                            downgrades.put(tableDowngrade.mTableName, tableDowngrade.mOperations) == null,
                            "Duplicate tableDowngrade for table " + tableDowngrade.mTableName + " in downgrade to "
                                    + offset);
                }

                mPendingDowngrades.put(offset, ImmutableMap.copyOf(downgrades));
                mCurrentOffset = offset;
                mUpgradeToCurrentOffsetDefined = false;

                return this;
            }

            public OldSchemasBuilder upgradeTo(int offset, Migration... migrations) {
                Preconditions.checkArgument(offset > 0,
                        "In upgradeTo(%s, ...): Upgrade offset should be greater than 0", offset);
                Preconditions.checkArgument(migrations != null, "In upgradeTo(%s, ...): migrations cannot be null",
                        offset);
                Preconditions.checkArgument(migrations.length > 0,
                        "In upgradeTo(%s, ...): migrations cannot be empty", offset);

                switch (migrations.length) {
                case 1:
                    Preconditions.checkArgument(migrations[0] != AUTO_MIGRATION,
                            "In upgradeTo(%s, ...): upgrades with a single auto() migration are implicitly performed for every revision without explicit upgradeTo()",
                            offset);
                    break;
                default:
                    Preconditions.checkArgument(
                            FluentIterable.from(Arrays.asList(migrations))
                                    .filter(Predicates.equalTo(AUTO_MIGRATION)).size() <= 1,
                            "In upgradeTo(%s, ...): only one auto() migration per upgrade is allowed", offset);
                }

                if (mCurrentOffset != null) {
                    if (mUpgradeToCurrentOffsetDefined) {
                        Preconditions.checkArgument(offset < mCurrentOffset,
                                "Downgrades and upgrades definitions should have descending offsets. The upgrade to %s is already defined, so upgrade offset %s should be lower than %s",
                                mCurrentOffset, offset, mCurrentOffset);
                    } else {
                        Preconditions.checkArgument(offset <= mCurrentOffset,
                                "Downgrades and upgrades definitions should have descending offsets. The upgrade offset %s should be lower or equal to %s",
                                offset, mCurrentOffset);
                    }
                }

                mPendingMigrations.put(offset, migrations);
                mUpgradeToCurrentOffsetDefined = true;
                mCurrentOffset = offset;
                return this;
            }

            public OldSchemasBuilder release(Release release) {
                if (mLastRelease != null) {
                    Preconditions.checkArgument(release.getSchemaVersion() <= mLastRelease.getSchemaVersion(),
                            "Releases should have non-ascending revision numbers. The previous release had version number %s, so the release with revision number %s is not valid",
                            mLastRelease.getSchemaVersion(), release.getSchemaVersion());
                }

                if (mCurrentRelease == null) {
                    mCurrentRelease = release;
                }
                processPendingSchemaParts(release);

                mCurrentOffset = null;
                mUpgradeToCurrentOffsetDefined = false;

                mLastRelease = release;

                return this;
            }

            public Schemas build() {
                processPendingSchemaParts(INITIAL_DB_SCHEMA);
                return Builder.this.build();
            }

            private void processPendingSchemaParts(Release release) {
                mReleasesBuilder.add(release);
                int baseRevisionNumber = release.getSchemaVersion();

                if (mLastRelease != null && mLastRelease.getSchemaVersion() == release.getSchemaVersion()) {
                    Preconditions.checkArgument(mPendingDowngrades.isEmpty() && mPendingMigrations.isEmpty(),
                            "Cannot define any upgrades or downgrades between releases %s and %s, because they have the same revision number (%s)",
                            mLastRelease, release, release.getSchemaVersion());
                }

                for (Integer downgradeOffset : mPendingDowngrades.keySet()) {
                    int revisionNumber = downgradeOffset + baseRevisionNumber;
                    if (mLastRelease != null) {
                        Preconditions.checkArgument(revisionNumber < mLastRelease.getSchemaVersion(),
                                "The downgrade with offset %s defined between release %s with version %s and release %s with version %s is outside of valid range [0, %s).",
                                downgradeOffset, release, release.getSchemaVersion(), mLastRelease,
                                mLastRelease.getSchemaVersion(),
                                (mLastRelease.getSchemaVersion() - release.getSchemaVersion()));
                    }
                    mDowngradesBuilder.put(revisionNumber, mPendingDowngrades.get(downgradeOffset));
                }
                mPendingDowngrades.clear();

                for (Integer migrationOffset : mPendingMigrations.keySet()) {
                    int revisionNumber = migrationOffset + baseRevisionNumber;
                    if (mLastRelease != null) {
                        Preconditions.checkArgument(revisionNumber <= mLastRelease.getSchemaVersion(),
                                "The upgrade with offset %s defined between release %s with version %s and release %s with version %s is outside of valid range (0, %s].",
                                migrationOffset, release, release.getSchemaVersion(), mLastRelease,
                                mLastRelease.getSchemaVersion(),
                                (mLastRelease.getSchemaVersion() - release.getSchemaVersion()));
                    }
                    mMigrationsBuilder.put(migrationOffset + baseRevisionNumber,
                            mPendingMigrations.get(migrationOffset));
                }
                mPendingMigrations.clear();
            }
        }

        private Schemas build() {
            return new Schemas(
                    mCurrentRelease != null ? mCurrentRelease.getSchemaVersion() + mCurrentRevisionOffset
                            : mCurrentRevisionOffset,
                    mTables, mDowngradesBuilder.build(), mMigrationsBuilder.build(), mReleasesBuilder.build());
        }
    }

    public interface Release {
        int getSchemaVersion();
    }

    static final MigrationsHelper AUTO_MIGRATION_HELPER = new MigrationsHelper();
    static final Migration AUTO_MIGRATION = new Migration() {
        @Override
        public void apply(int version, SQLiteDatabase database, Schemas schemas, Context context) {
            ImmutableSet<String> modifiedTables = schemas.getTablesModifiedInRevision(version);
            if (!modifiedTables.isEmpty()) {
                ImmutableSet<String> newTables = schemas.getSchema(version).getTables();
                ImmutableSet<String> oldTables = schemas.getSchema(version - 1).getTables();

                SetView<String> addedTables = Sets.difference(newTables, oldTables);
                Migrations.create(addedTables).apply(version, database, schemas, context);

                SetView<String> droppedTables = Sets.difference(oldTables, newTables);
                Migrations.drop(droppedTables).apply(version, database, schemas, context);

                SetView<String> commonTables = Sets.intersection(oldTables, newTables);
                SetView<String> alteredTables = Sets.intersection(commonTables, modifiedTables);

                for (String table : alteredTables) {
                    SimpleTableMigration.of(table).using(AUTO_MIGRATION_HELPER).apply(version, database, schemas,
                            context);
                }
            }
        }
    };

    private static final Migration[] AUTO_MIGRATIONS = new Migration[] { AUTO_MIGRATION };

    Migration[] to(int revision) {
        return Objects.firstNonNull(mMigrations.get(revision), AUTO_MIGRATIONS);
    }

    /**
     * Use {@link #upgrade(android.content.Context, android.database.sqlite.SQLiteDatabase, int, int)} instead.
     */
    @Deprecated
    public void upgrade(int fromVersion, Context context, SQLiteDatabase database) {
        upgrade(context, database, fromVersion, getCurrentRevisionNumber());
    }

    public void upgrade(Context context, SQLiteDatabase database, int fromVersion, int toVersion) {
        for (int version = fromVersion + 1; version <= toVersion; version++) {
            Log.d(TAG, "Perform migration to " + version);
            for (Migration migration : to(version)) {
                migration.apply(version, database, this, context);
            }
        }
        clearRevisionsCache();
    }

    private void clearRevisionsCache() {
        Set<Integer> cachedRevisions = Sets.newHashSet(mRevisions.asMap().keySet());
        cachedRevisions.remove(mCurrentRevision.get());
        mRevisions.invalidateAll(cachedRevisions);
    }
}