com.parse.OfflineStore.java Source code

Java tutorial

Introduction

Here is the source code for com.parse.OfflineStore.java

Source

/*
 * Copyright (c) 2015-present, Parse, LLC.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */
package com.parse;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.text.TextUtils;
import android.util.Pair;

import com.parse.OfflineQueryLogic.ConstraintMatcher;

import org.json.JSONException;
import org.json.JSONObject;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.WeakHashMap;

import bolts.Capture;
import bolts.Continuation;
import bolts.Task;

/** package */
class OfflineStore {

    /**
     * SQLite has a max of 999 SQL variables in a single statement.
     */
    private static final int MAX_SQL_VARIABLES = 999;

    /**
     * Extends the normal JSON -> ParseObject decoding to also deal with placeholders for new objects
     * that have been saved offline.
     */
    private class OfflineDecoder extends ParseDecoder {
        // A map of UUID -> Task that will be finished once the given ParseObject is loaded.
        // The Tasks should all be finished before decode is called.
        private Map<String, Task<ParseObject>> offlineObjects;

        private OfflineDecoder(Map<String, Task<ParseObject>> offlineObjects) {
            this.offlineObjects = offlineObjects;
        }

        @Override
        public Object decode(Object object) {
            // If we see an offline id, make sure to decode it.
            if (object instanceof JSONObject && ((JSONObject) object).optString("__type").equals("OfflineObject")) {
                String uuid = ((JSONObject) object).optString("uuid");
                return offlineObjects.get(uuid).getResult();
            }

            /*
             * Embedded objects can't show up here, because we never stored them that way offline.
             */

            return super.decode(object);
        }
    }

    /**
     * An encoder that can encode objects that are available offline. After using this encoder, you
     * must call whenFinished() and wait for its result to be finished before the results of the
     * encoding will be valid.
     */
    private class OfflineEncoder extends ParseEncoder {
        private ParseSQLiteDatabase db;
        private ArrayList<Task<Void>> tasks = new ArrayList<>();
        private final Object tasksLock = new Object();

        /**
         * Creates an encoder.
         *
         * @param db
         *          A database connection to use.
         */
        public OfflineEncoder(ParseSQLiteDatabase db) {
            this.db = db;
        }

        /**
         * The results of encoding an object with this encoder will not be valid until the task returned
         * by this method is finished.
         */
        public Task<Void> whenFinished() {
            return Task.whenAll(tasks).continueWithTask(new Continuation<Void, Task<Void>>() {
                @Override
                public Task<Void> then(Task<Void> ignore) throws Exception {
                    synchronized (tasksLock) {
                        // It might be better to return an aggregate error here.
                        for (Task<Void> task : tasks) {
                            if (task.isFaulted() || task.isCancelled()) {
                                return task;
                            }
                        }
                        tasks.clear();
                        return Task.forResult((Void) null);
                    }
                }
            });
        }

        /**
         * Implements an encoding strategy for Parse Objects that uses offline ids when necessary.
         */
        @Override
        public JSONObject encodeRelatedObject(ParseObject object) {
            try {
                if (object.getObjectId() != null) {
                    JSONObject result = new JSONObject();
                    result.put("__type", "Pointer");
                    result.put("objectId", object.getObjectId());
                    result.put("className", object.getClassName());
                    return result;
                }

                final JSONObject result = new JSONObject();
                result.put("__type", "OfflineObject");
                synchronized (tasksLock) {
                    tasks.add(getOrCreateUUIDAsync(object, db).onSuccess(new Continuation<String, Void>() {
                        @Override
                        public Void then(Task<String> task) throws Exception {
                            result.put("uuid", task.getResult());
                            return null;
                        }
                    }));
                }
                return result;
            } catch (JSONException e) {
                // This can literally never happen.
                throw new RuntimeException(e);
            }
        }
    }

    // Lock for all members of the store.
    final private Object lock = new Object();

    // Helper for accessing the database.
    final private OfflineSQLiteOpenHelper helper;

    /**
     * In-memory map of UUID -> ParseObject. This is used so that we can always return the same
     * instance for a given object. The only objects in this map are ones that are in the database.
     */
    final private WeakValueHashMap<String, ParseObject> uuidToObjectMap = new WeakValueHashMap<>();

    /**
     * In-memory map of ParseObject -> UUID. This is used to that when we see an unsaved ParseObject
     * that's already in the database, we can update the same record in the database. It stores a Task
     * instead of the String, because one thread may want to reserve the spot. Once the task is
     * finished, there will be a row for this UUID in the database.
     */
    final private WeakHashMap<ParseObject, Task<String>> objectToUuidMap = new WeakHashMap<>();

    /**
     * In-memory set of ParseObjects that have been fetched from the local database already. If the
     * object is in the map, a fetch of it has been started. If the value is a finished task, then the
     * fetch was completed.
     */
    final private WeakHashMap<ParseObject, Task<ParseObject>> fetchedObjects = new WeakHashMap<>();

    /**
     * Used by the static method to create the singleton.
     */
    /* package */ OfflineStore(Context context) {
        this(new OfflineSQLiteOpenHelper(context));
    }

    /* package */ OfflineStore(OfflineSQLiteOpenHelper helper) {
        this.helper = helper;
    }

    /**
     * Gets the UUID for the given object, if it has one. Otherwise, creates a new UUID for the object
     * and adds a new row to the database for the object with no data.
     */
    private Task<String> getOrCreateUUIDAsync(final ParseObject object, ParseSQLiteDatabase db) {
        final String newUUID = UUID.randomUUID().toString();
        final Task<String>.TaskCompletionSource tcs = Task.create();

        synchronized (lock) {
            Task<String> uuidTask = objectToUuidMap.get(object);
            if (uuidTask != null) {
                return uuidTask;
            }

            // The object doesn't have a UUID yet, so we're gonna have to make one.
            objectToUuidMap.put(object, tcs.getTask());
            uuidToObjectMap.put(newUUID, object);
            fetchedObjects.put(object, tcs.getTask().onSuccess(new Continuation<String, ParseObject>() {
                @Override
                public ParseObject then(Task<String> task) throws Exception {
                    return object;
                }
            }));
        }

        /*
         * We need to put a placeholder row in the database so that later on, the save can just be an
         * update. This could be a pointer to an object that itself never gets saved offline, in which
         * case the consumer will just have to deal with that.
         */
        ContentValues values = new ContentValues();
        values.put(OfflineSQLiteOpenHelper.KEY_UUID, newUUID);
        values.put(OfflineSQLiteOpenHelper.KEY_CLASS_NAME, object.getClassName());
        db.insertOrThrowAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, values)
                .continueWith(new Continuation<Void, Void>() {
                    @Override
                    public Void then(Task<Void> task) throws Exception {
                        // This will signal that the UUID does represent a row in the database.
                        tcs.setResult(newUUID);
                        return null;
                    }
                });

        return tcs.getTask();
    }

    /**
     * Gets an unfetched pointer to an object in the db, based on its uuid. The object may or may not
     * be in memory, but it must be in the database. If it is already in memory, that instance will be
     * returned. Since this is only for creating pointers to objects that are referenced by other
     * objects in the data store, that's a fair assumption.
     *
     * @param uuid
     *          The object to retrieve.
     * @param db
     *          The database instance to retrieve from.
     * @return The object with that UUID.
     */
    private <T extends ParseObject> Task<T> getPointerAsync(final String uuid, ParseSQLiteDatabase db) {
        synchronized (lock) {
            @SuppressWarnings("unchecked")
            T existing = (T) uuidToObjectMap.get(uuid);
            if (existing != null) {
                return Task.forResult(existing);
            }
        }

        /*
         * We want to just return the pointer, but we have to look in the database to know if there's
         * something with this classname and object id already.
         */

        String[] select = { OfflineSQLiteOpenHelper.KEY_CLASS_NAME, OfflineSQLiteOpenHelper.KEY_OBJECT_ID };
        String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?";
        String[] args = { uuid };
        return db.queryAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, select, where, args)
                .onSuccess(new Continuation<Cursor, T>() {
                    @Override
                    public T then(Task<Cursor> task) throws Exception {
                        Cursor cursor = task.getResult();
                        cursor.moveToFirst();
                        if (cursor.isAfterLast()) {
                            cursor.close();
                            throw new IllegalStateException("Attempted to find non-existent uuid " + uuid);
                        }

                        synchronized (lock) {
                            // We need to check again since another task might have come around and added it to
                            // the map.
                            //TODO (grantland): Maybe we should insert a Task that is resolved when the query
                            // completes like we do in getOrCreateUUIDAsync?
                            @SuppressWarnings("unchecked")
                            T existing = (T) uuidToObjectMap.get(uuid);
                            if (existing != null) {
                                return existing;
                            }

                            String className = cursor.getString(0);
                            String objectId = cursor.getString(1);
                            cursor.close();
                            @SuppressWarnings("unchecked")
                            T pointer = (T) ParseObject.createWithoutData(className, objectId);
                            /*
                             * If it doesn't have an objectId, we don't really need the UUID, and this simplifies
                             * some other logic elsewhere if we only update the map for new objects.
                             */
                            if (objectId == null) {
                                uuidToObjectMap.put(uuid, pointer);
                                objectToUuidMap.put(pointer, Task.forResult(uuid));
                            }
                            return pointer;
                        }
                    }
                });
    }

    /**
     * Runs a ParseQuery against the store's contents.
     *
     * @return The objects that match the query's constraints.
     */
    /* package for OfflineQueryLogic */ <T extends ParseObject> Task<List<T>> findAsync(ParseQuery.State<T> query,
            ParseUser user, ParsePin pin, ParseSQLiteDatabase db) {
        return findAsync(query, user, pin, false, db);
    }

    /**
     * Runs a ParseQuery against the store's contents. May cause any instances of T to get fetched
     * from the offline database. TODO(klimt): Should the query consider objects that are in memory,
     * but not in the offline store?
     *
     * @param query The query.
     * @param user The user making the query.
     * @param pin (Optional) The pin we are querying across. If null, all pins.
     * @param isCount True if we are doing a count.
     * @param db The SQLiteDatabase.
     * @param <T> Subclass of ParseObject.
     * @return The objects that match the query's constraints.
     */
    private <T extends ParseObject> Task<List<T>> findAsync(final ParseQuery.State<T> query, final ParseUser user,
            final ParsePin pin, final boolean isCount, final ParseSQLiteDatabase db) {
        /*
         * This is currently unused, but is here to allow future querying across objects that are in the
         * process of being deleted eventually.
         */
        final boolean includeIsDeletingEventually = false;

        final OfflineQueryLogic queryLogic = new OfflineQueryLogic(this);

        final List<T> results = new ArrayList<>();

        Task<Cursor> queryTask;
        if (pin == null) {
            String table = OfflineSQLiteOpenHelper.TABLE_OBJECTS;
            String[] select = { OfflineSQLiteOpenHelper.KEY_UUID };
            String where = OfflineSQLiteOpenHelper.KEY_CLASS_NAME + "=?";
            if (!includeIsDeletingEventually) {
                where += " AND " + OfflineSQLiteOpenHelper.KEY_IS_DELETING_EVENTUALLY + "=0";
            }
            String[] args = { query.className() };

            queryTask = db.queryAsync(table, select, where, args);
        } else {
            Task<String> uuidTask = objectToUuidMap.get(pin);
            if (uuidTask == null) {
                // Pin was never saved locally, therefore there won't be any results.
                return Task.forResult(results);
            }

            queryTask = uuidTask.onSuccessTask(new Continuation<String, Task<Cursor>>() {
                @Override
                public Task<Cursor> then(Task<String> task) throws Exception {
                    String uuid = task.getResult();

                    String table = OfflineSQLiteOpenHelper.TABLE_OBJECTS + " A " + " INNER JOIN "
                            + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES + " B " + " ON A."
                            + OfflineSQLiteOpenHelper.KEY_UUID + "=B." + OfflineSQLiteOpenHelper.KEY_UUID;
                    String[] select = { "A." + OfflineSQLiteOpenHelper.KEY_UUID };
                    String where = OfflineSQLiteOpenHelper.KEY_CLASS_NAME + "=?" + " AND "
                            + OfflineSQLiteOpenHelper.KEY_KEY + "=?";
                    if (!includeIsDeletingEventually) {
                        where += " AND " + OfflineSQLiteOpenHelper.KEY_IS_DELETING_EVENTUALLY + "=0";
                    }
                    String[] args = { query.className(), uuid };

                    return db.queryAsync(table, select, where, args);
                }
            });
        }

        return queryTask.onSuccessTask(new Continuation<Cursor, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Cursor> task) throws Exception {
                Cursor cursor = task.getResult();
                List<String> uuids = new ArrayList<>();
                for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
                    uuids.add(cursor.getString(0));
                }
                cursor.close();

                // Find objects that match the where clause.
                final ConstraintMatcher<T> matcher = queryLogic.createMatcher(query, user);

                Task<Void> checkedAllObjects = Task.forResult(null);
                for (final String uuid : uuids) {
                    final Capture<T> object = new Capture<>();

                    checkedAllObjects = checkedAllObjects.onSuccessTask(new Continuation<Void, Task<T>>() {
                        @Override
                        public Task<T> then(Task<Void> task) throws Exception {
                            return getPointerAsync(uuid, db);
                        }
                    }).onSuccessTask(new Continuation<T, Task<T>>() {
                        @Override
                        public Task<T> then(Task<T> task) throws Exception {
                            object.set(task.getResult());
                            return fetchLocallyAsync(object.get(), db);
                        }
                    }).onSuccessTask(new Continuation<T, Task<Boolean>>() {
                        @Override
                        public Task<Boolean> then(Task<T> task) throws Exception {
                            if (!object.get().isDataAvailable()) {
                                return Task.forResult(false);
                            }
                            return matcher.matchesAsync(object.get(), db);
                        }
                    }).onSuccess(new Continuation<Boolean, Void>() {
                        @Override
                        public Void then(Task<Boolean> task) {
                            if (task.getResult()) {
                                results.add(object.get());
                            }
                            return null;
                        }
                    });
                }

                return checkedAllObjects;
            }
        }).onSuccessTask(new Continuation<Void, Task<List<T>>>() {
            @Override
            public Task<List<T>> then(Task<Void> task) throws Exception {
                // Sort by any sort operators.
                OfflineQueryLogic.sort(results, query);

                // Apply the skip.
                List<T> trimmedResults = results;
                int skip = query.skip();
                if (!isCount && skip >= 0) {
                    skip = Math.min(query.skip(), trimmedResults.size());
                    trimmedResults = trimmedResults.subList(skip, trimmedResults.size());
                }

                // Trim to the limit.
                int limit = query.limit();
                if (!isCount && limit >= 0 && trimmedResults.size() > limit) {
                    trimmedResults = trimmedResults.subList(0, limit);
                }

                // Fetch the includes.
                Task<Void> fetchedIncludesTask = Task.forResult(null);
                for (final T object : trimmedResults) {
                    fetchedIncludesTask = fetchedIncludesTask.onSuccessTask(new Continuation<Void, Task<Void>>() {
                        @Override
                        public Task<Void> then(Task<Void> task) throws Exception {
                            return OfflineQueryLogic.fetchIncludesAsync(OfflineStore.this, object, query, db);
                        }
                    });
                }

                final List<T> finalTrimmedResults = trimmedResults;
                return fetchedIncludesTask.onSuccess(new Continuation<Void, List<T>>() {
                    @Override
                    public List<T> then(Task<Void> task) throws Exception {
                        return finalTrimmedResults;
                    }
                });
            }
        });
    }

    /**
     * Gets the data for the given object from the offline database. Returns a task that will be
     * completed if data for the object was available. If the object is not in the cache, the task
     * will be faulted, with a CACHE_MISS error.
     *
     * @param object
     *          The object to fetch.
     * @param db
     *          A database connection to use.
     */
    /* package for OfflineQueryLogic */ <T extends ParseObject> Task<T> fetchLocallyAsync(final T object,
            final ParseSQLiteDatabase db) {
        final Task<T>.TaskCompletionSource tcs = Task.create();
        Task<String> uuidTask;

        synchronized (lock) {
            if (fetchedObjects.containsKey(object)) {
                /*
                 * The object has already been fetched from the offline store, so any data that's in there
                 * is already reflected in the in-memory version. There's nothing more to do.
                 */
                //noinspection unchecked
                return (Task<T>) fetchedObjects.get(object);
            }

            /*
             * Put a placeholder so that anyone else who attempts to fetch this object will just wait for
             * this call to finish doing it.
             */
            //noinspection unchecked
            fetchedObjects.put(object, (Task<ParseObject>) tcs.getTask());

            uuidTask = objectToUuidMap.get(object);
        }
        String className = object.getClassName();
        String objectId = object.getObjectId();

        /*
         * If this gets set, then it will contain data from the offline store that needs to be merged
         * into the existing object in memory.
         */
        Task<String> jsonStringTask = Task.forResult(null);

        if (objectId == null) {
            // This Object has never been saved to Parse.
            if (uuidTask == null) {
                /*
                 * This object was not pulled from the data store or previously saved to it, so there's
                 * nothing that can be fetched from it. This isn't an error, because it's really convenient
                 * to try to fetch objects from the offline store just to make sure they are up-to-date, and
                 * we shouldn't force developers to specially handle this case.
                 */
            } else {
                /*
                 * This object is a new ParseObject that is known to the data store, but hasn't been
                 * fetched. The only way this could happen is if the object had previously been stored in
                 * the offline store, then the object was removed from memory (maybe by rebooting), and then
                 * a object with a pointer to it was fetched, so we only created the pointer. We need to
                 * pull the data out of the database using the UUID.
                 */
                final String[] select = { OfflineSQLiteOpenHelper.KEY_JSON };
                final String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?";
                final Capture<String> uuid = new Capture<>();
                jsonStringTask = uuidTask.onSuccessTask(new Continuation<String, Task<Cursor>>() {
                    @Override
                    public Task<Cursor> then(Task<String> task) throws Exception {
                        uuid.set(task.getResult());
                        String[] args = { uuid.get() };
                        return db.queryAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, select, where, args);
                    }
                }).onSuccess(new Continuation<Cursor, String>() {
                    @Override
                    public String then(Task<Cursor> task) throws Exception {
                        Cursor cursor = task.getResult();
                        cursor.moveToFirst();
                        if (cursor.isAfterLast()) {
                            cursor.close();
                            throw new IllegalStateException("Attempted to find non-existent uuid " + uuid.get());
                        }
                        String json = cursor.getString(0);
                        cursor.close();

                        return json;
                    }
                });
            }
        } else {
            if (uuidTask != null) {
                /*
                 * This object is an existing ParseObject, and we must've already pulled its data out of the
                 * offline store, or else we wouldn't know its UUID. This should never happen.
                 */
                tcs.setError(new IllegalStateException("This object must have already been "
                        + "fetched from the local datastore, but isn't marked as fetched."));
                synchronized (lock) {
                    // Forget we even tried to fetch this object, so that retries will actually... retry.
                    fetchedObjects.remove(object);
                }
                return tcs.getTask();
            }

            /*
             * We've got a pointer to an existing ParseObject, but we've never pulled its data out of the
             * offline store. Since fetching from the server forces a fetch from the offline store, that
             * means this is a pointer. We need to try to find any existing entry for this object in the
             * database.
             */
            String[] select = { OfflineSQLiteOpenHelper.KEY_JSON, OfflineSQLiteOpenHelper.KEY_UUID };
            String where = String.format("%s = ? AND %s = ?", OfflineSQLiteOpenHelper.KEY_CLASS_NAME,
                    OfflineSQLiteOpenHelper.KEY_OBJECT_ID);
            String[] args = { className, objectId };
            jsonStringTask = db.queryAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, select, where, args)
                    .onSuccess(new Continuation<Cursor, String>() {
                        @Override
                        public String then(Task<Cursor> task) throws Exception {
                            Cursor cursor = task.getResult();
                            cursor.moveToFirst();
                            if (cursor.isAfterLast()) {
                                /*
                                 * This is a pointer that came from Parse that references an object that has
                                 * never been saved in the offline store before. This just means there's no data
                                 * in the store that needs to be merged into the object.
                                 */
                                cursor.close();
                                throw new ParseException(ParseException.CACHE_MISS,
                                        "This object is not available in the offline cache.");
                            }

                            // we should fetch its data and record its UUID for future reference.
                            String jsonString = cursor.getString(0);
                            String newUUID = cursor.getString(1);
                            cursor.close();

                            synchronized (lock) {
                                /*
                                 * It's okay to put this object into the uuid map. No one will try to fetch
                                 * it, because it's already in the fetchedObjects map. And no one will try to
                                 * save to it without fetching it first, so everything should be just fine.
                                 */
                                objectToUuidMap.put(object, Task.forResult(newUUID));
                                uuidToObjectMap.put(newUUID, object);
                            }

                            return jsonString;
                        }
                    });
        }

        return jsonStringTask.onSuccessTask(new Continuation<String, Task<Void>>() {
            @Override
            public Task<Void> then(Task<String> task) throws Exception {
                String jsonString = task.getResult();
                if (jsonString == null) {
                    /*
                     * This means we tried to fetch an object from the database that was never actually saved
                     * locally. This probably means that its parent object was saved locally and we just
                     * created a pointer to this object. This should be considered a cache miss.
                     */
                    return Task.forError(new ParseException(ParseException.CACHE_MISS,
                            "Attempted to fetch an object offline which was never saved to the offline cache."));
                }
                final JSONObject json;
                try {
                    /*
                     * We can assume that whatever is in the database is the last known server state. The only
                     * things to maintain from the in-memory object are any changes since the object was last
                     * put in the database.
                     */
                    json = new JSONObject(jsonString);
                } catch (JSONException e) {
                    return Task.forError(e);
                }

                // Fetch all the offline objects before we decode.
                final Map<String, Task<ParseObject>> offlineObjects = new HashMap<>();

                (new ParseTraverser() {
                    @Override
                    protected boolean visit(Object object) {
                        if (object instanceof JSONObject
                                && ((JSONObject) object).optString("__type").equals("OfflineObject")) {
                            String uuid = ((JSONObject) object).optString("uuid");
                            offlineObjects.put(uuid, getPointerAsync(uuid, db));
                        }
                        return true;
                    }
                }).setTraverseParseObjects(false).setYieldRoot(false).traverse(json);

                return Task.whenAll(offlineObjects.values()).onSuccess(new Continuation<Void, Void>() {
                    @Override
                    public Void then(Task<Void> task) throws Exception {
                        object.mergeREST(object.getState(), json, new OfflineDecoder(offlineObjects));
                        return null;
                    }
                });
            }
        }).continueWithTask(new Continuation<Void, Task<T>>() {
            @Override
            public Task<T> then(Task<Void> task) throws Exception {
                if (task.isCancelled()) {
                    tcs.setCancelled();
                } else if (task.isFaulted()) {
                    tcs.setError(task.getError());
                } else {
                    tcs.setResult(object);
                }
                return tcs.getTask();
            }
        });
    }

    /**
     * Gets the data for the given object from the offline database. Returns a task that will be
     * completed if data for the object was available. If the object is not in the cache, the task
     * will be faulted, with a CACHE_MISS error.
     *
     * @param object
     *          The object to fetch.
     */
    /* package */ <T extends ParseObject> Task<T> fetchLocallyAsync(final T object) {
        return runWithManagedConnection(new SQLiteDatabaseCallable<Task<T>>() {
            @Override
            public Task<T> call(ParseSQLiteDatabase db) {
                return fetchLocallyAsync(object, db);
            }
        });
    }

    /**
     * Stores a single object in the local database. If the object is a pointer, isn't dirty, and has
     * an objectId already, it may not be saved, since it would provide no useful data.
     *
     * @param object
     *          The object to save.
     * @param db
     *          A database connection to use.
     */
    private Task<Void> saveLocallyAsync(final String key, final ParseObject object, final ParseSQLiteDatabase db) {
        // If this is just a clean, unfetched pointer known to Parse, then there is nothing to save.
        if (object.getObjectId() != null && !object.isDataAvailable() && !object.hasChanges()
                && !object.hasOutstandingOperations()) {
            return Task.forResult(null);
        }

        final Capture<String> uuidCapture = new Capture<>();

        // Make sure we have a UUID for the object to be saved.
        return getOrCreateUUIDAsync(object, db).onSuccessTask(new Continuation<String, Task<Void>>() {
            @Override
            public Task<Void> then(Task<String> task) throws Exception {
                String uuid = task.getResult();
                uuidCapture.set(uuid);
                return updateDataForObjectAsync(uuid, object, db);
            }
        }).onSuccessTask(new Continuation<Void, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Void> task) throws Exception {
                final ContentValues values = new ContentValues();
                values.put(OfflineSQLiteOpenHelper.KEY_KEY, key);
                values.put(OfflineSQLiteOpenHelper.KEY_UUID, uuidCapture.get());
                return db.insertWithOnConflict(OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, values,
                        SQLiteDatabase.CONFLICT_IGNORE);
            }
        });
    }

    /**
     * Stores an object (and optionally, every object it points to recursively) in the local database.
     * If any of the objects have not been fetched from Parse, they will not be stored. However, if
     * they have changed data, the data will be retained. To get the objects back later, you can use a
     * ParseQuery with a cache policy that uses the local cache, or you can create an unfetched
     * pointer with ParseObject.createWithoutData() and then call fetchFromLocalDatastore() on it. If you modify
     * the object after saving it locally, such as by fetching it or saving it, those changes will
     * automatically be applied to the cache.
     *
     * Any objects previously stored with the same key will be removed from the local database.
     *
     * @param object Root object
     * @param includeAllChildren {@code true} to recursively save all pointers.
     * @param db DB connection
     * @return A Task that will be resolved when saving is complete
     */
    private Task<Void> saveLocallyAsync(final ParseObject object, final boolean includeAllChildren,
            final ParseSQLiteDatabase db) {
        final ArrayList<ParseObject> objectsInTree = new ArrayList<>();
        // Fetch all objects locally in case they are being re-added
        if (!includeAllChildren) {
            objectsInTree.add(object);
        } else {
            (new ParseTraverser() {
                @Override
                protected boolean visit(Object object) {
                    if (object instanceof ParseObject) {
                        objectsInTree.add((ParseObject) object);
                    }
                    return true;
                }
            }).setYieldRoot(true).setTraverseParseObjects(true).traverse(object);
        }

        return saveLocallyAsync(object, objectsInTree, db);
    }

    private Task<Void> saveLocallyAsync(final ParseObject object, List<ParseObject> children,
            final ParseSQLiteDatabase db) {
        final List<ParseObject> objects = children != null ? new ArrayList<>(children)
                : new ArrayList<ParseObject>();
        if (!objects.contains(object)) {
            objects.add(object);
        }

        // Call saveLocallyAsync for each of them individually.
        final List<Task<Void>> tasks = new ArrayList<>();
        for (ParseObject obj : objects) {
            tasks.add(fetchLocallyAsync(obj, db).makeVoid());
        }

        return Task.whenAll(tasks).continueWithTask(new Continuation<Void, Task<String>>() {
            @Override
            public Task<String> then(Task<Void> task) throws Exception {
                return objectToUuidMap.get(object);
            }
        }).onSuccessTask(new Continuation<String, Task<Void>>() {
            @Override
            public Task<Void> then(Task<String> task) throws Exception {
                String uuid = task.getResult();
                if (uuid == null) {
                    // The root object was never stored in the offline store, so nothing to unpin.
                    return null;
                }

                // Delete all objects locally corresponding to the key we're trying to use in case it was
                // used before (overwrite)
                return unpinAsync(uuid, db);
            }
        }).onSuccessTask(new Continuation<Void, Task<String>>() {
            @Override
            public Task<String> then(Task<Void> task) throws Exception {
                return getOrCreateUUIDAsync(object, db);
            }
        }).onSuccessTask(new Continuation<String, Task<Void>>() {
            @Override
            public Task<Void> then(Task<String> task) throws Exception {
                String uuid = task.getResult();

                // Call saveLocallyAsync for each of them individually.
                final List<Task<Void>> tasks = new ArrayList<>();
                for (ParseObject obj : objects) {
                    tasks.add(saveLocallyAsync(uuid, obj, db));
                }

                return Task.whenAll(tasks);
            }
        });
    }

    private Task<Void> unpinAsync(final ParseObject object, final ParseSQLiteDatabase db) {
        Task<String> uuidTask = objectToUuidMap.get(object);
        if (uuidTask == null) {
            // The root object was never stored in the offline store, so nothing to unpin.
            return Task.forResult(null);
        }
        return uuidTask.continueWithTask(new Continuation<String, Task<Void>>() {
            @Override
            public Task<Void> then(Task<String> task) throws Exception {
                final String uuid = task.getResult();
                if (uuid == null) {
                    // The root object was never stored in the offline store, so nothing to unpin.
                    return Task.forResult(null);
                }
                return unpinAsync(uuid, db);
            }
        });
    }

    private Task<Void> unpinAsync(final String key, final ParseSQLiteDatabase db) {
        final List<String> uuidsToDelete = new LinkedList<>();
        // A continueWithTask that ends with "return task" is essentially a try-finally.
        return Task.forResult((Void) null).continueWithTask(new Continuation<Void, Task<Cursor>>() {
            @Override
            public Task<Cursor> then(Task<Void> task) throws Exception {
                // Fetch all uuids from Dependencies for key=? grouped by uuid having a count of 1
                String sql = "SELECT " + OfflineSQLiteOpenHelper.KEY_UUID + " FROM "
                        + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES + " WHERE " + OfflineSQLiteOpenHelper.KEY_KEY
                        + "=? AND " + OfflineSQLiteOpenHelper.KEY_UUID + " IN (" + " SELECT "
                        + OfflineSQLiteOpenHelper.KEY_UUID + " FROM " + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES
                        + " GROUP BY " + OfflineSQLiteOpenHelper.KEY_UUID + " HAVING COUNT("
                        + OfflineSQLiteOpenHelper.KEY_UUID + ")=1" + ")";
                String[] args = { key };
                return db.rawQueryAsync(sql, args);
            }
        }).onSuccessTask(new Continuation<Cursor, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Cursor> task) throws Exception {
                // DELETE FROM Objects

                Cursor cursor = task.getResult();
                while (cursor.moveToNext()) {
                    uuidsToDelete.add(cursor.getString(0));
                }
                cursor.close();

                return deleteObjects(uuidsToDelete, db);
            }
        }).onSuccessTask(new Continuation<Void, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Void> task) throws Exception {
                // DELETE FROM Dependencies
                String where = OfflineSQLiteOpenHelper.KEY_KEY + "=?";
                String[] args = { key };
                return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, where, args);
            }
        }).onSuccess(new Continuation<Void, Void>() {
            @Override
            public Void then(Task<Void> task) throws Exception {
                synchronized (lock) {
                    // Remove uuids from memory
                    for (String uuid : uuidsToDelete) {
                        ParseObject object = uuidToObjectMap.get(uuid);
                        if (object != null) {
                            objectToUuidMap.remove(object);
                            uuidToObjectMap.remove(uuid);
                        }
                    }
                }
                return null;
            }
        });
    }

    private Task<Void> deleteObjects(final List<String> uuids, final ParseSQLiteDatabase db) {
        if (uuids.size() <= 0) {
            return Task.forResult(null);
        }

        // SQLite has a max 999 SQL variables in a statement, so we need to split it up into manageable
        // chunks. We can do this because we're already in a transaction.
        if (uuids.size() > MAX_SQL_VARIABLES) {
            return deleteObjects(uuids.subList(0, MAX_SQL_VARIABLES), db)
                    .onSuccessTask(new Continuation<Void, Task<Void>>() {
                        @Override
                        public Task<Void> then(Task<Void> task) throws Exception {
                            return deleteObjects(uuids.subList(MAX_SQL_VARIABLES, uuids.size()), db);
                        }
                    });
        }

        String[] placeholders = new String[uuids.size()];
        for (int i = 0; i < placeholders.length; i++) {
            placeholders[i] = "?";
        }
        String where = OfflineSQLiteOpenHelper.KEY_UUID + " IN (" + TextUtils.join(",", placeholders) + ")";
        // dynamic args
        String[] args = uuids.toArray(new String[uuids.size()]);
        return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, where, args);
    }

    /**
     * Takes an object that has been fetched from the database before and updates it with whatever
     * data is in memory. This will only be used when data comes back from the server after a fetch or
     * a save.
     */
    /* package */ Task<Void> updateDataForObjectAsync(final ParseObject object) {
        Task<ParseObject> fetched;
        // Make sure the object is fetched.
        synchronized (lock) {
            fetched = fetchedObjects.get(object);
            if (fetched == null) {
                return Task
                        .forError(new IllegalStateException("An object cannot be updated if it wasn't fetched."));
            }
        }
        return fetched.continueWithTask(new Continuation<ParseObject, Task<Void>>() {
            @Override
            public Task<Void> then(Task<ParseObject> task) throws Exception {
                if (task.isFaulted()) {
                    // Catch CACHE_MISS
                    //noinspection ThrowableResultOfMethodCallIgnored
                    if (task.getError() instanceof ParseException
                            && ((ParseException) task.getError()).getCode() == ParseException.CACHE_MISS) {
                        return Task.forResult(null);
                    }
                    return task.makeVoid();
                }

                return helper.getWritableDatabaseAsync()
                        .continueWithTask(new Continuation<ParseSQLiteDatabase, Task<Void>>() {
                            @Override
                            public Task<Void> then(Task<ParseSQLiteDatabase> task) throws Exception {
                                final ParseSQLiteDatabase db = task.getResult();
                                return db.beginTransactionAsync()
                                        .onSuccessTask(new Continuation<Void, Task<Void>>() {
                                            @Override
                                            public Task<Void> then(Task<Void> task) throws Exception {
                                                return updateDataForObjectAsync(object, db)
                                                        .onSuccessTask(new Continuation<Void, Task<Void>>() {
                                                            @Override
                                                            public Task<Void> then(Task<Void> task)
                                                                    throws Exception {
                                                                return db.setTransactionSuccessfulAsync();
                                                            }
                                                        }).continueWithTask(new Continuation<Void, Task<Void>>() {
                                                            // } finally {
                                                            @Override
                                                            public Task<Void> then(Task<Void> task)
                                                                    throws Exception {
                                                                db.endTransactionAsync();
                                                                db.closeAsync();
                                                                return task;
                                                            }
                                                        });
                                            }
                                        });
                            }
                        });
            }
        });
    }

    private Task<Void> updateDataForObjectAsync(final ParseObject object, final ParseSQLiteDatabase db) {
        // Make sure the object has a UUID.
        Task<String> uuidTask;
        synchronized (lock) {
            uuidTask = objectToUuidMap.get(object);
            if (uuidTask == null) {
                // It was fetched, but it has no UUID. That must mean it isn't actually in the database.
                return Task.forResult(null);
            }
        }
        return uuidTask.onSuccessTask(new Continuation<String, Task<Void>>() {
            @Override
            public Task<Void> then(Task<String> task) throws Exception {
                String uuid = task.getResult();
                return updateDataForObjectAsync(uuid, object, db);
            }
        });
    }

    private Task<Void> updateDataForObjectAsync(final String uuid, final ParseObject object,
            final ParseSQLiteDatabase db) {
        // Now actually encode the object as JSON.
        OfflineEncoder encoder = new OfflineEncoder(db);
        final JSONObject json = object.toRest(encoder);

        return encoder.whenFinished().onSuccessTask(new Continuation<Void, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Void> task) throws Exception {
                // Put the JSON in the database.
                String className = object.getClassName();
                String objectId = object.getObjectId();
                int isDeletingEventually = json.getInt(ParseObject.KEY_IS_DELETING_EVENTUALLY);

                final ContentValues values = new ContentValues();
                values.put(OfflineSQLiteOpenHelper.KEY_CLASS_NAME, className);
                values.put(OfflineSQLiteOpenHelper.KEY_JSON, json.toString());
                if (objectId != null) {
                    values.put(OfflineSQLiteOpenHelper.KEY_OBJECT_ID, objectId);
                }
                values.put(OfflineSQLiteOpenHelper.KEY_IS_DELETING_EVENTUALLY, isDeletingEventually);
                String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?";
                String[] args = { uuid };
                return db.updateAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, values, where, args).makeVoid();
            }
        });
    }

    /* package */ Task<Void> deleteDataForObjectAsync(final ParseObject object) {
        return helper.getWritableDatabaseAsync()
                .continueWithTask(new Continuation<ParseSQLiteDatabase, Task<Void>>() {
                    @Override
                    public Task<Void> then(Task<ParseSQLiteDatabase> task) throws Exception {
                        final ParseSQLiteDatabase db = task.getResult();
                        return db.beginTransactionAsync().onSuccessTask(new Continuation<Void, Task<Void>>() {
                            @Override
                            public Task<Void> then(Task<Void> task) throws Exception {
                                return deleteDataForObjectAsync(object, db)
                                        .onSuccessTask(new Continuation<Void, Task<Void>>() {
                                            @Override
                                            public Task<Void> then(Task<Void> task) throws Exception {
                                                return db.setTransactionSuccessfulAsync();
                                            }
                                        }).continueWithTask(new Continuation<Void, Task<Void>>() {
                                            // } finally {
                                            @Override
                                            public Task<Void> then(Task<Void> task) throws Exception {
                                                db.endTransactionAsync();
                                                db.closeAsync();
                                                return task;
                                            }
                                        });
                            }
                        });
                    }
                });
    }

    private Task<Void> deleteDataForObjectAsync(final ParseObject object, final ParseSQLiteDatabase db) {
        final Capture<String> uuid = new Capture<>();

        // Make sure the object has a UUID.
        Task<String> uuidTask;
        synchronized (lock) {
            uuidTask = objectToUuidMap.get(object);
            if (uuidTask == null) {
                // It was fetched, but it has no UUID. That must mean it isn't actually in the database.
                return Task.forResult(null);
            }
        }
        uuidTask = uuidTask.onSuccessTask(new Continuation<String, Task<String>>() {
            @Override
            public Task<String> then(Task<String> task) throws Exception {
                uuid.set(task.getResult());
                return task;
            }
        });

        // If the object was the root of a pin, unpin it.
        Task<Void> unpinTask = uuidTask.onSuccessTask(new Continuation<String, Task<Cursor>>() {
            @Override
            public Task<Cursor> then(Task<String> task) throws Exception {
                // Find all the roots for this object.
                String[] select = { OfflineSQLiteOpenHelper.KEY_KEY };
                String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?";
                String[] args = { uuid.get() };
                return db.queryAsync(OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, select, where, args);
            }
        }).onSuccessTask(new Continuation<Cursor, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Cursor> task) throws Exception {
                // Try to unpin this object from the pin label if it's a root of the ParsePin.
                Cursor cursor = task.getResult();
                List<String> uuids = new ArrayList<>();
                for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
                    uuids.add(cursor.getString(0));
                }
                cursor.close();

                List<Task<Void>> tasks = new ArrayList<>();
                for (final String uuid : uuids) {
                    Task<Void> unpinTask = getPointerAsync(uuid, db)
                            .onSuccessTask(new Continuation<ParseObject, Task<ParsePin>>() {
                                @Override
                                public Task<ParsePin> then(Task<ParseObject> task) throws Exception {
                                    ParsePin pin = (ParsePin) task.getResult();
                                    return fetchLocallyAsync(pin, db);
                                }
                            }).continueWithTask(new Continuation<ParsePin, Task<Void>>() {
                                @Override
                                public Task<Void> then(Task<ParsePin> task) throws Exception {
                                    ParsePin pin = task.getResult();

                                    List<ParseObject> modified = pin.getObjects();
                                    if (modified == null || !modified.contains(object)) {
                                        return task.makeVoid();
                                    }

                                    modified.remove(object);
                                    if (modified.size() == 0) {
                                        return unpinAsync(uuid, db);
                                    }

                                    pin.setObjects(modified);
                                    return saveLocallyAsync(pin, true, db);
                                }
                            });
                    tasks.add(unpinTask);
                }

                return Task.whenAll(tasks);
            }
        });

        // Delete the object from the Local Datastore in case it wasn't the root of a pin.
        return unpinTask.onSuccessTask(new Continuation<Void, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Void> task) throws Exception {
                String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?";
                String[] args = { uuid.get() };
                return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, where, args);
            }
        }).onSuccessTask(new Continuation<Void, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Void> task) throws Exception {
                String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?";
                String[] args = { uuid.get() };
                return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, where, args);
            }
        }).onSuccessTask(new Continuation<Void, Task<Void>>() {
            @Override
            public Task<Void> then(Task<Void> task) throws Exception {
                synchronized (lock) {
                    // Clean up
                    //TODO (grantland): we should probably clean up uuidToObjectMap and objectToUuidMap, but
                    // getting the uuid requires a task and things might get a little funky...
                    fetchedObjects.remove(object);
                }
                return task;
            }
        });
    }

    //region ParsePin

    private Task<ParsePin> getParsePin(final String name, ParseSQLiteDatabase db) {
        ParseQuery.State<ParsePin> query = new ParseQuery.State.Builder<>(ParsePin.class)
                .whereEqualTo(ParsePin.KEY_NAME, name).build();

        /* We need to call directly to the OfflineStore since we don't want/need a user to query for
         * ParsePins
         */
        return findAsync(query, null, null, db).onSuccess(new Continuation<List<ParsePin>, ParsePin>() {
            @Override
            public ParsePin then(Task<List<ParsePin>> task) throws Exception {
                ParsePin pin = null;
                if (task.getResult() != null && task.getResult().size() > 0) {
                    pin = task.getResult().get(0);
                }

                //TODO (grantland): What do we do if there are more than 1 result?

                if (pin == null) {
                    pin = ParseObject.create(ParsePin.class);
                    pin.setName(name);
                }
                return pin;
            }
        });
    }

    /* package */ <T extends ParseObject> Task<Void> pinAllObjectsAsync(final String name, final List<T> objects,
            final boolean includeChildren) {
        return runWithManagedTransaction(new SQLiteDatabaseCallable<Task<Void>>() {
            @Override
            public Task<Void> call(ParseSQLiteDatabase db) {
                return pinAllObjectsAsync(name, objects, includeChildren, db);
            }
        });
    }

    private <T extends ParseObject> Task<Void> pinAllObjectsAsync(final String name, final List<T> objects,
            final boolean includeChildren, final ParseSQLiteDatabase db) {
        if (objects == null || objects.size() == 0) {
            return Task.forResult(null);
        }

        return getParsePin(name, db).onSuccessTask(new Continuation<ParsePin, Task<Void>>() {
            @Override
            public Task<Void> then(Task<ParsePin> task) throws Exception {
                ParsePin pin = task.getResult();

                //TODO (grantland): change to use relations. currently the related PO are only getting saved
                // offline as pointers.
                //        ParseRelation<ParseObject> relation = pin.getRelation(KEY_OBJECTS);
                //        relation.add(object);

                // Hack to store collections in a pin
                List<ParseObject> modified = pin.getObjects();
                if (modified == null) {
                    modified = new ArrayList<ParseObject>(objects);
                } else {
                    for (ParseObject object : objects) {
                        if (!modified.contains(object)) {
                            modified.add(object);
                        }
                    }
                }
                pin.setObjects(modified);

                if (includeChildren) {
                    return saveLocallyAsync(pin, true, db);
                }
                return saveLocallyAsync(pin, pin.getObjects(), db);
            }
        });
    }

    /* package */ <T extends ParseObject> Task<Void> unpinAllObjectsAsync(final String name,
            final List<T> objects) {
        return runWithManagedTransaction(new SQLiteDatabaseCallable<Task<Void>>() {
            @Override
            public Task<Void> call(ParseSQLiteDatabase db) {
                return unpinAllObjectsAsync(name, objects, db);
            }
        });
    }

    private <T extends ParseObject> Task<Void> unpinAllObjectsAsync(String name, final List<T> objects,
            final ParseSQLiteDatabase db) {
        if (objects == null || objects.size() == 0) {
            return Task.forResult(null);
        }

        return getParsePin(name, db).onSuccessTask(new Continuation<ParsePin, Task<Void>>() {
            @Override
            public Task<Void> then(Task<ParsePin> task) throws Exception {
                ParsePin pin = task.getResult();

                //TODO (grantland): change to use relations. currently the related PO are only getting saved
                // offline as pointers.
                //        ParseRelation<ParseObject> relation = pin.getRelation(KEY_OBJECTS);
                //        relation.remove(object);

                // Hack to store collections in a pin
                List<ParseObject> modified = pin.getObjects();
                if (modified == null) {
                    // Unpin a pin that doesn't exist. Wat?
                    return Task.forResult(null);
                }

                modified.removeAll(objects);
                if (modified.size() == 0) {
                    return unpinAsync(pin, db);
                }
                pin.setObjects(modified);

                return saveLocallyAsync(pin, true, db);
            }
        });
    }

    /* package */ Task<Void> unpinAllObjectsAsync(final String name) {
        return runWithManagedTransaction(new SQLiteDatabaseCallable<Task<Void>>() {
            @Override
            public Task<Void> call(ParseSQLiteDatabase db) {
                return unpinAllObjectsAsync(name, db);
            }
        });
    }

    private Task<Void> unpinAllObjectsAsync(final String name, final ParseSQLiteDatabase db) {
        return getParsePin(name, db).continueWithTask(new Continuation<ParsePin, Task<Void>>() {
            @Override
            public Task<Void> then(Task<ParsePin> task) throws Exception {
                if (task.isFaulted()) {
                    return task.makeVoid();
                }
                ParsePin pin = task.getResult();
                return unpinAsync(pin, db);
            }
        });
    }

    /* package */ <T extends ParseObject> Task<List<T>> findFromPinAsync(final String name,
            final ParseQuery.State<T> state, final ParseUser user) {
        return runWithManagedConnection(new SQLiteDatabaseCallable<Task<List<T>>>() {
            @Override
            public Task<List<T>> call(ParseSQLiteDatabase db) {
                return findFromPinAsync(name, state, user, db);
            }
        });
    }

    private <T extends ParseObject> Task<List<T>> findFromPinAsync(final String name,
            final ParseQuery.State<T> state, final ParseUser user, final ParseSQLiteDatabase db) {
        Task<ParsePin> task;
        if (name != null) {
            task = getParsePin(name, db);
        } else {
            task = Task.forResult(null);
        }
        return task.onSuccessTask(new Continuation<ParsePin, Task<List<T>>>() {
            @Override
            public Task<List<T>> then(Task<ParsePin> task) throws Exception {
                ParsePin pin = task.getResult();
                return findAsync(state, user, pin, false, db);
            }
        });
    }

    /* package */ <T extends ParseObject> Task<Integer> countFromPinAsync(final String name,
            final ParseQuery.State<T> state, final ParseUser user) {
        return runWithManagedConnection(new SQLiteDatabaseCallable<Task<Integer>>() {
            @Override
            public Task<Integer> call(ParseSQLiteDatabase db) {
                return countFromPinAsync(name, state, user, db);
            }
        });
    }

    private <T extends ParseObject> Task<Integer> countFromPinAsync(final String name,
            final ParseQuery.State<T> state, final ParseUser user, final ParseSQLiteDatabase db) {
        Task<ParsePin> task;
        if (name != null) {
            task = getParsePin(name, db);
        } else {
            task = Task.forResult(null);
        }
        return task.onSuccessTask(new Continuation<ParsePin, Task<Integer>>() {
            @Override
            public Task<Integer> then(Task<ParsePin> task) throws Exception {
                ParsePin pin = task.getResult();
                return findAsync(state, user, pin, true, db).onSuccess(new Continuation<List<T>, Integer>() {
                    @Override
                    public Integer then(Task<List<T>> task) throws Exception {
                        return task.getResult().size();
                    }
                });
            }
        });
    }

    //endregion

    //region Single Instance

    /**
     * In-memory map of (className, objectId) -> ParseObject. This is used so that we can always
     * return the same instance for a given object. Objects in this map may or may not be in the
     * database.
     */
    private final WeakValueHashMap<Pair<String, String>, ParseObject> classNameAndObjectIdToObjectMap = new WeakValueHashMap<>();

    /**
     * This should be called by the ParseObject constructor notify the store that there is an object
     * with this className and objectId.
     */
    /* package */ void registerNewObject(ParseObject object) {
        synchronized (lock) {
            String objectId = object.getObjectId();
            if (objectId != null) {
                String className = object.getClassName();
                Pair<String, String> classNameAndObjectId = Pair.create(className, objectId);
                classNameAndObjectIdToObjectMap.put(classNameAndObjectId, object);
            }
        }
    }

    /* package */ void unregisterObject(ParseObject object) {
        synchronized (lock) {
            String objectId = object.getObjectId();
            if (objectId != null) {
                classNameAndObjectIdToObjectMap.remove(Pair.create(object.getClassName(), objectId));
            }
        }
    }

    /**
     * This should only ever be called from ParseObject.createWithoutData().
     *
     * @return a pair of ParseObject and Boolean. The ParseObject is the object. The Boolean is true
     *         iff the object was newly created.
     */
    /* package */ ParseObject getObject(String className, String objectId) {
        if (objectId == null) {
            throw new IllegalStateException("objectId cannot be null.");
        }

        Pair<String, String> classNameAndObjectId = Pair.create(className, objectId);
        // This lock should never be held by anyone doing disk or database access.
        synchronized (lock) {
            return classNameAndObjectIdToObjectMap.get(classNameAndObjectId);
        }
    }

    /**
     * When an object is finished saving, it gets an objectId. Then it should call this method to
     * clean up the bookeeping around ids.
     */
    /* package */ void updateObjectId(ParseObject object, String oldObjectId, String newObjectId) {
        if (oldObjectId != null) {
            if (oldObjectId.equals(newObjectId)) {
                return;
            }
            throw new RuntimeException("objectIds cannot be changed in offline mode.");
        }

        String className = object.getClassName();
        Pair<String, String> classNameAndNewObjectId = Pair.create(className, newObjectId);

        synchronized (lock) {
            // See if there's already an entry for the new object id.
            ParseObject existing = classNameAndObjectIdToObjectMap.get(classNameAndNewObjectId);
            if (existing != null && existing != object) {
                throw new RuntimeException(
                        "Attempted to change an objectId to one that's " + "already known to the Offline Store.");
            }

            // Okay, all clear to add the new reference.
            classNameAndObjectIdToObjectMap.put(classNameAndNewObjectId, object);
        }
    }

    //endregion

    /**
     * Wraps SQLite operations with a managed SQLite connection.
     */
    private <T> Task<T> runWithManagedConnection(final SQLiteDatabaseCallable<Task<T>> callable) {
        return helper.getWritableDatabaseAsync().onSuccessTask(new Continuation<ParseSQLiteDatabase, Task<T>>() {
            @Override
            public Task<T> then(Task<ParseSQLiteDatabase> task) throws Exception {
                final ParseSQLiteDatabase db = task.getResult();
                return callable.call(db).continueWithTask(new Continuation<T, Task<T>>() {
                    @Override
                    public Task<T> then(Task<T> task) throws Exception {
                        db.closeAsync();
                        return task;
                    }
                });
            }
        });
    }

    /**
     * Wraps SQLite operations with a managed SQLite connection and transaction.
     */
    private Task<Void> runWithManagedTransaction(final SQLiteDatabaseCallable<Task<Void>> callable) {
        return helper.getWritableDatabaseAsync().onSuccessTask(new Continuation<ParseSQLiteDatabase, Task<Void>>() {
            @Override
            public Task<Void> then(Task<ParseSQLiteDatabase> task) throws Exception {
                final ParseSQLiteDatabase db = task.getResult();
                return db.beginTransactionAsync().onSuccessTask(new Continuation<Void, Task<Void>>() {
                    @Override
                    public Task<Void> then(Task<Void> task) throws Exception {
                        return callable.call(db).onSuccessTask(new Continuation<Void, Task<Void>>() {
                            @Override
                            public Task<Void> then(Task<Void> task) throws Exception {
                                return db.setTransactionSuccessfulAsync();
                            }
                        }).continueWithTask(new Continuation<Void, Task<Void>>() {
                            @Override
                            public Task<Void> then(Task<Void> task) throws Exception {
                                db.endTransactionAsync();
                                db.closeAsync();
                                return task;
                            }
                        });
                    }
                });
            }
        });
    }

    private interface SQLiteDatabaseCallable<T> {
        T call(ParseSQLiteDatabase db);
    }

    /*
     * Methods for testing.
     */

    /**
     * Clears all in-memory caches so that data must be retrieved from disk.
     */
    void simulateReboot() {
        synchronized (lock) {
            uuidToObjectMap.clear();
            objectToUuidMap.clear();
            classNameAndObjectIdToObjectMap.clear();
            fetchedObjects.clear();
        }
    }

    /**
     * Clears the database on disk.
     */
    void clearDatabase(Context context) {
        helper.clearDatabase(context);
    }
}