Repository: parse-community/Parse-SDK-JS
Pull request #612 (2018-07-27 01:15:51)

Local Datastore
This is based on discussion https://github.com/parse-community/Parse-SDK-JS/issues/77

This is a Work In Progress.

codecov[bot]: # [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612?src=pr&el=h1) Report
> Merging [#612](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612?src=pr&el=desc) into [master](https://codecov.io/gh/parse-community/Parse-SDK-JS/commit/358a335b02827c72663839348988880a1b490f75?src=pr&el=desc) will **decrease** coverage by `0.71%`.
> The diff coverage is `50.57%`.

[![Impacted file tree graph](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612/graphs/tree.svg?token=8nCENM0QHj&src=pr&height=150&width=650)](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #612      +/-   ##
==========================================
- Coverage   84.57%   83.85%   -0.72%     
==========================================
  Files          48       51       +3     
  Lines        4039     4126      +87     
  Branches      911      917       +6     
==========================================
+ Hits         3416     3460      +44     
- Misses        623      666      +43
```


| [Impacted Files](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [src/ParseObject.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612/diff?src=pr&el=tree#diff-c3JjL1BhcnNlT2JqZWN0Lmpz) | `83.97% <14.28%> (-2.62%)` | :arrow_down: |
| [src/Parse.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612/diff?src=pr&el=tree#diff-c3JjL1BhcnNlLmpz) | `80% <50%> (-0.89%)` | :arrow_down: |
| [src/LocalDatastore.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612/diff?src=pr&el=tree#diff-c3JjL0xvY2FsRGF0YXN0b3JlLmpz) | `52% <52%> (ø)` | |
| [src/CoreManager.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612/diff?src=pr&el=tree#diff-c3JjL0NvcmVNYW5hZ2VyLmpz) | `97.26% <60%> (-2.74%)` | :arrow_down: |
| [src/LocalDatastoreController.localStorage.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612/diff?src=pr&el=tree#diff-c3JjL0xvY2FsRGF0YXN0b3JlQ29udHJvbGxlci5sb2NhbFN0b3JhZ2UuanM=) | `80% <80%> (ø)` | |
| [src/LocalDatastoreController.default.js](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612/diff?src=pr&el=tree#diff-c3JjL0xvY2FsRGF0YXN0b3JlQ29udHJvbGxlci5kZWZhdWx0Lmpz) | `91.66% <91.66%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612?src=pr&el=footer). Last update [358a335...f8ec58f](https://codecov.io/gh/parse-community/Parse-SDK-JS/pull/612?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).

dplewis: @flovilmart Can you check Travis for me? I've been having gulp issues. 

Also can you do a quick run though of what I've done so far? Its pretty close to being done.

flovilmart: Error: Cannot find module 'utf-8-validate' from '/home/travis/build/parse-community/Parse-SDK-JS/node_modules/parse/node_modules/ws/lib'


It’s the error that I see before the integration tests, during the browserify build 
dplewis: @flovilmart I got both 

Error: Cannot find module 'utf-8-validate' from 'xxx/node_modules/ws/lib'
Error: Cannot find module 'bufferutil' from 'xxx/node_modules/ws/lib'

I tried to npm install --save them but ended up with another error
flovilmart: Uhm, that’s odd, try reverting ws to an earlier version then :)
dplewis: @flovilmart I fixed the issue. It was a lot of circular dependency. Since localStorage isn't available I'll have to mock it along with some other tests. 

Can you have a quick look through?
flovilmart: I’ll have a look. The diff hit for coverage is only 21.11% can we do something?
dplewis: @flovilmart I'll try
flovilmart: I’ll review over the weekend 
dplewis: @flovilmart The OfflineQuery pretty much has the same support liveQuery does. I can add more integration tests for it if needed.

I also have add that if a PinName doesn't have any objects attached then delete that PinName


flovilmart: Perhaps we could have an offline query engine written in JS that we use in both projects? Perhaps too much trouble for now. 

Any limitation on the queries we can run? Are they properly failing so at development time one knows which queries can / cannot run locally?
flovilmart: I’ll do the full review tomorrow. Have a good one!
dplewis: Those changes have been reverted and requested changes made
dplewis: @flovilmart Thanks for the feedback. I'm going to be busy for a long while. If you want to jump in on this PR feel free to clean it up.
flovilmart: @dplewis I'll close the PR then, pending we have time to finish up the implementation :) Thanks 🙏  again a 1000 times for getting this off the ground!
dplewis: @flovilmart I forgot I started this PR because I needed it for something
dplewis: @flovilmart Merging https://github.com/parse-community/parse-server/pull/4933 is messing with the tests. Can you look into it?
dplewis: @flovilmart I've added some documentation there are a few `TODO` questions I had.

I'll update the javascript guide. Should I add the same documentation to the API Reference like the iOS SDK does?

[iOS Api Pin](http://parseplatform.org/Parse-SDK-iOS-OSX/api/Classes/PFObject.html#/c:objc(cs)PFObject(im)pin)

flovilmart: The API reference will be generated from the docs. If you run ‘npm run docs’ you’ll have the api reference generate in the ‘out’ folder 
dplewis: I mean adding something like this to this PR for every pin function?

```
Synchronously stores the object and every object it points to in the local datastore, recursively, using a default pin name: PFObjectDefaultPin.

If those other objects have not been fetched from Parse, they will not be stored. However, if they have changed data, all the changes will be retained. To get the objects back later, you can use a PFQuery that uses PFQuery.-fromLocalDatastore, or you can create an unfetched pointer with +objectWithoutDataWithClassName:objectId: and then call -fetchFromLocalDatastore on it.
```
flovilmart: Uhm, would be nice, but it’s not synchronous 😬 :) 
dplewis: I just copied that from the iOS api reference.
dplewis: @flovilmart I basically copied the ParseQueryTest.js and ran it against the LocalDatastore, I got them all to run except one. The only limitation I see is regex. Mongo regex and Javascript RegExp have different modifiers except (i) case insensitive and (m) multi lines.

LiveQuery is missing $and, $nor, $containedBy, $geoWithin, $geoIntercepts. I'll add them here first then add them to the Live Queries later.

I'm running into a weird issue with the tests, I want you to look into it.
flovilmart: > LiveQuery is missing $and, $nor, $containedBy, $geoWithin, $geoIntercepts. I'll add them here first then add them to the Live Queries later.

We perhaps need to consolidate the offlineQuery first, perhaps we could use the one from the JS SDK in liveQuery after :)

For regex support, leave it out for now offline. 

Good Job!

Whst’s The issue you have?
dplewis: > For regex support, leave it out for now offline.

Regex works it just limited support for modifiers.

> What’s The issue you have?

This line here. So I'm running the tests against three adapters. I can run them individually or two at a time but not all three at once. I end up with an `invalid session token` error for every test.

https://github.com/parse-community/Parse-SDK-JS/pull/612/files#diff-f42538d45a95bd688e048debdb1ec1c5R28
flovilmart: @dplewis I'll see if i see something to do with it
flovilmart: @dplewis updated the testfile, all pass on me side with the change
dplewis: Sweet cleanup
flovilmart: Patch and project diff are very low
dplewis: I know. After everything in the todo list. I'll go back over it and clean it up.
flovilmart: @dplewis thanks! I'll have a look!
dplewis: @acinader I opened a PR in the docs repo https://github.com/parse-community/docs/pull/558
acinader: man, that's seriously helpful. I have barely touched the IOS SDK, so this makes more sense now.
flovilmart: Where do we stand on this one?
dplewis: It should be good to go!
flovilmart: Is it completely opt-in? And anyone not wanting the feature should be able to experience no regression?
dplewis: Yes you have to enable it with `Parse.enableLocalDatastore()`. 

Patch:
diff --git a/integration/cloud/main.js b/integration/cloud/main.js
index e154374..8cdde53 100644
--- a/integration/cloud/main.js
+++ b/integration/cloud/main.js
@@ -7,6 +7,13 @@ Parse.Cloud.define("bar", function(request) {
   }
 });
 
+Parse.Cloud.define('TestFetchFromLocalDatastore', function (request) {
+  const object = new Parse.Object('Item');
+  object.id = request.params.id;
+  object.set('foo', 'changed');
+  return object.save();
+});
+
 Parse.Cloud.define('CloudFunctionUndefined', function() {
   return undefined;
 });
diff --git a/src/CoreManager.js b/src/CoreManager.js
index 65ba665..a9fbb9e 100644
--- a/src/CoreManager.js
+++ b/src/CoreManager.js
@@ -108,6 +108,13 @@ type StorageController = {
   removeItemAsync: (path: string) => Promise;
   clear: () => void;
 };
+type LocalDatastoreController = {
+  fromPinWithName: (name: string) => ?any;
+  pinWithName: (name: string, objects: any) => void;
+  unPinWithName: (name: string) => void;
+  getAllContents: () => ?any;
+  clear: () => void;
+};
 type UserController = {
   setCurrentUser: (user: ParseUser) => Promise;
   currentUser: () => ?ParseUser;
@@ -144,6 +151,7 @@ type Config = {
   SchemaController?: SchemaController,
   SessionController?: SessionController,
   StorageController?: StorageController,
+  LocalDatastoreController?: LocalDatastoreController,
   UserController?: UserController,
   HooksController?: HooksController,
 };
@@ -331,6 +339,23 @@ module.exports = {
     config['StorageController'] = controller;
   },
 
+  setLocalDatastoreController(controller: LocalDatastoreController) {
+    requireMethods('LocalDatastoreController', ['pinWithName', 'fromPinWithName', 'unPinWithName', 'getAllContents', 'clear'], controller);
+    config['LocalDatastoreController'] = controller;
+  },
+
+  getLocalDatastoreController(): LocalDatastoreController {
+    return config['LocalDatastoreController'];
+  },
+
+  setLocalDatastore(store: any) {
+    config['LocalDatastore'] = store;
+  },
+
+  getLocalDatastore() {
+    return config['LocalDatastore'];
+  },
+
   getStorageController(): StorageController {
     return config['StorageController'];
   },
diff --git a/src/LocalDatastore.js b/src/LocalDatastore.js
new file mode 100644
index 0000000..3b34c1a
--- /dev/null
+++ b/src/LocalDatastore.js
@@ -0,0 +1,295 @@
+/**
+ * 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.
+ *
+ * @flow
+ */
+
+import CoreManager from './CoreManager';
+
+import type ParseObject from './ParseObject';
+
+const DEFAULT_PIN = '_default';
+const PIN_PREFIX = 'parsePin_';
+
+const LocalDatastore = {
+  fromPinWithName(name: string): Promise {
+    const controller = CoreManager.getLocalDatastoreController();
+    return controller.fromPinWithName(name);
+  },
+
+  pinWithName(name: string, value: any): Promise {
+    const controller = CoreManager.getLocalDatastoreController();
+    return controller.pinWithName(name, value);
+  },
+
+  unPinWithName(name: string): Promise {
+    const controller = CoreManager.getLocalDatastoreController();
+    return controller.unPinWithName(name);
+  },
+
+  _getAllContents(): Promise {
+    const controller = CoreManager.getLocalDatastoreController();
+    return controller.getAllContents();
+  },
+
+  _clear(): Promise {
+    const controller = CoreManager.getLocalDatastoreController();
+    return controller.clear();
+  },
+
+  // Pin the object and children recursively
+  // Saves the object and children key to Pin Name
+  async _handlePinWithName(name: string, object: ParseObject): Promise {
+    const pinName = this.getPinName(name);
+    const objects = this._getChildren(object);
+    objects[this.getKeyForObject(object)] = object._toFullJSON();
+    for (const objectKey in objects) {
+      await this.pinWithName(objectKey, objects[objectKey]);
+    }
+    const pinned = await this.fromPinWithName(pinName) || [];
+    const objectIds = Object.keys(objects);
+    const toPin = [...new Set([...pinned, ...objectIds])];
+    await this.pinWithName(pinName, toPin);
+  },
+
+  // Removes object and children keys from pin name
+  // Keeps the object and children pinned
+  async _handleUnPinWithName(name: string, object: ParseObject) {
+    const localDatastore = await this._getAllContents();
+    const pinName = this.getPinName(name);
+    const objects = this._getChildren(object);
+    const objectIds = Object.keys(objects);
+    objectIds.push(this.getKeyForObject(object));
+    let pinned = localDatastore[pinName] || [];
+    pinned = pinned.filter(item => !objectIds.includes(item));
+    if (pinned.length == 0) {
+      await this.unPinWithName(pinName);
+      delete localDatastore[pinName];
+    } else {
+      await this.pinWithName(pinName, pinned);
+      localDatastore[pinName] = pinned;
+    }
+    for (const objectKey of objectIds) {
+      let hasReference = false;
+      for (const key in localDatastore) {
+        if (key === DEFAULT_PIN || key.startsWith(PIN_PREFIX)) {
+          const pinnedObjects = localDatastore[key] || [];
+          if (pinnedObjects.includes(objectKey)) {
+            hasReference = true;
+            break;
+          }
+        }
+      }
+      if (!hasReference) {
+        await this.unPinWithName(objectKey);
+      }
+    }
+  },
+
+  // Retrieve all pointer fields from object recursively
+  _getChildren(object: ParseObject) {
+    const encountered = {};
+    const json = object._toFullJSON();
+    for (const key in json) {
+      if (json[key].__type && json[key].__type === 'Object') {
+        this._traverse(json[key], encountered);
+      }
+    }
+    return encountered;
+  },
+
+  _traverse(object: any, encountered: any) {
+    if (!object.objectId) {
+      return;
+    } else {
+      const objectKey = this.getKeyForObject(object);
+      if (encountered[objectKey]) {
+        return;
+      }
+      encountered[objectKey] = object;
+    }
+    for (const key in object) {
+      let json = object[key];
+      if (!object[key]) {
+        json = object;
+      }
+      if (json.__type && json.__type === 'Object') {
+        this._traverse(json, encountered);
+      }
+    }
+  },
+
+  // Transform keys in pin name to objects
+  async _serializeObjectsFromPinName(name: string) {
+    const localDatastore = await this._getAllContents();
+    const allObjects = [];
+    for (const key in localDatastore) {
+      if (key !== DEFAULT_PIN && !key.startsWith(PIN_PREFIX)) {
+        allObjects.push(localDatastore[key]);
+      }
+    }
+    if (!name) {
+      return Promise.resolve(allObjects);
+    }
+    const pinName = await this.getPinName(name);
+    const pinned = await this.fromPinWithName(pinName);
+    if (!Array.isArray(pinned)) {
+      return Promise.resolve([]);
+    }
+    const objects = pinned.map(async (objectKey) => await this.fromPinWithName(objectKey));
+    return Promise.all(objects);
+  },
+
+  // Replaces object pointers with pinned pointers
+  // The object pointers may contain old data
+  // Uses Breadth First Search Algorithm
+  async _serializeObject(objectKey: string, localDatastore: any) {
+    let LDS = localDatastore;
+    if (!LDS) {
+      LDS = await this._getAllContents();
+    }
+    const root = LDS[objectKey];
+    if (!root) {
+      return null;
+    }
+
+    const queue = [];
+    const meta = {};
+    let uniqueId = 0;
+    meta[uniqueId] = root;
+    queue.push(uniqueId);
+
+    while(queue.length !== 0) {
+      const nodeId = queue.shift();
+      const subTreeRoot = meta[nodeId];
+      for (const field in subTreeRoot) {
+        const value = subTreeRoot[field];
+        if (value.__type && value.__type === 'Object') {
+          const key = this.getKeyForObject(value);
+          const pointer = LDS[key];
+          if (pointer) {
+            uniqueId++;
+            meta[uniqueId] = pointer;
+            subTreeRoot[field] = pointer;
+            queue.push(uniqueId);
+          }
+        }
+      }
+    }
+    return root;
+  },
+
+  // Called when an object is save / fetched
+  // Update object pin value
+  async _updateObjectIfPinned(object: ParseObject) {
+    if (!this.isEnabled) {
+      return;
+    }
+    const objectKey = this.getKeyForObject(object);
+    const pinned = await this.fromPinWithName(objectKey);
+    if (pinned) {
+      await this.pinWithName(objectKey, object._toFullJSON());
+    }
+  },
+
+  // Called when object is destroyed
+  // Unpin object and remove all references from pin names
+  // TODO: Destroy children?
+  async _destroyObjectIfPinned(object: ParseObject) {
+    if (!this.isEnabled) {
+      return;
+    }
+    const localDatastore = await this._getAllContents();
+    const objectKey = this.getKeyForObject(object);
+    const pin = localDatastore[objectKey];
+    if (!pin) {
+      return;
+    }
+    await this.unPinWithName(objectKey);
+    delete localDatastore[objectKey];
+
+    for (const key in localDatastore) {
+      if (key === DEFAULT_PIN || key.startsWith(PIN_PREFIX)) {
+        let pinned = localDatastore[key] || [];
+        if (pinned.includes(objectKey)) {
+          pinned = pinned.filter(item => item !== objectKey);
+          if (pinned.length == 0) {
+            await this.unPinWithName(key);
+            delete localDatastore[key];
+          } else {
+            await this.pinWithName(key, pinned);
+            localDatastore[key] = pinned;
+          }
+        }
+      }
+    }
+  },
+
+  // Update pin and references of the unsaved object
+  async _updateLocalIdForObject(localId, object: ParseObject) {
+    if (!this.isEnabled) {
+      return;
+    }
+    const localKey = `${object.className}_${localId}`;
+    const objectKey = this.getKeyForObject(object);
+
+    const unsaved = await this.fromPinWithName(localKey);
+    if (!unsaved) {
+      return;
+    }
+    await this.unPinWithName(localKey);
+    await this.pinWithName(objectKey, unsaved);
+
+    const localDatastore = await this._getAllContents();
+    for (const key in localDatastore) {
+      if (key === DEFAULT_PIN || key.startsWith(PIN_PREFIX)) {
+        let pinned = localDatastore[key] || [];
+        if (pinned.includes(localKey)) {
+          pinned = pinned.filter(item => item !== localKey);
+          pinned.push(objectKey);
+          await this.pinWithName(key, pinned);
+          localDatastore[key] = pinned;
+        }
+      }
+    }
+  },
+
+  getKeyForObject(object: any) {
+    const objectId = object.objectId || object._getId();
+    return `${object.className}_${objectId}`;
+  },
+
+  getPinName(pinName: ?string) {
+    if (!pinName || pinName === DEFAULT_PIN) {
+      return DEFAULT_PIN;
+    }
+    return PIN_PREFIX + pinName;
+  },
+
+  checkIfEnabled() {
+    if (!this.isEnabled) {
+      console.log('Parse.enableLocalDatastore() must be called first'); // eslint-disable-line no-console
+    }
+    return this.isEnabled;
+  }
+};
+
+LocalDatastore.DEFAULT_PIN = DEFAULT_PIN;
+LocalDatastore.PIN_PREFIX = PIN_PREFIX;
+LocalDatastore.isEnabled = false;
+
+module.exports = LocalDatastore;
+
+if (process.env.PARSE_BUILD === 'react-native') {
+  CoreManager.setLocalDatastoreController(require('./LocalDatastoreController.react-native'));
+} else if (process.env.PARSE_BUILD === 'browser') {
+  CoreManager.setLocalDatastoreController(require('./LocalDatastoreController.browser'));
+} else {
+  CoreManager.setLocalDatastoreController(require('./LocalDatastoreController.default'));
+}
+CoreManager.setLocalDatastore(LocalDatastore);
diff --git a/src/LocalDatastoreController.browser.js b/src/LocalDatastoreController.browser.js
new file mode 100644
index 0000000..3212fdc
--- /dev/null
+++ b/src/LocalDatastoreController.browser.js
@@ -0,0 +1,54 @@
+/**
+ * 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.
+ *
+ * @flow
+ */
+
+/* global localStorage */
+
+const LocalDatastoreController = {
+  fromPinWithName(name: string): Promise {
+    const values = localStorage.getItem(name);
+    if (!values) {
+      return Promise.resolve(null);
+    }
+    const objects = JSON.parse(values);
+    return Promise.resolve(objects);
+  },
+
+  pinWithName(name: string, value: any): Promise {
+    try {
+      const values = JSON.stringify(value);
+      localStorage.setItem(name, values);
+    } catch (e) {
+      // Quota exceeded, possibly due to Safari Private Browsing mode
+      console.log(e.message); // eslint-disable-line no-console
+    }
+    return Promise.resolve();
+  },
+
+  unPinWithName(name: string): Promise {
+    return Promise.resolve(localStorage.removeItem(name));
+  },
+
+  getAllContents(): Promise {
+    const LDS = {};
+    for (let i = 0; i < localStorage.length; i += 1) {
+      const key = localStorage.key(i);
+      const value = localStorage.getItem(key);
+      LDS[key] = JSON.parse(value);
+    }
+    return Promise.resolve(LDS);
+  },
+
+  clear(): Promise {
+    return Promise.resolve(localStorage.clear());
+  }
+};
+
+module.exports = LocalDatastoreController;
diff --git a/src/LocalDatastoreController.default.js b/src/LocalDatastoreController.default.js
new file mode 100644
index 0000000..62a5cfc
--- /dev/null
+++ b/src/LocalDatastoreController.default.js
@@ -0,0 +1,42 @@
+/**
+ * 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.
+ *
+ * @flow
+ */
+
+const memMap = {};
+const LocalDatastoreController = {
+  fromPinWithName(name: string): ?any {
+    if (memMap.hasOwnProperty(name)) {
+      return memMap[name];
+    }
+    return null;
+  },
+
+  pinWithName(name: string, value: any) {
+    memMap[name] = value;
+  },
+
+  unPinWithName(name: string) {
+    delete memMap[name];
+  },
+
+  getAllContents() {
+    return memMap;
+  },
+
+  clear() {
+    for (var key in memMap) {
+      if (memMap.hasOwnProperty(key)) {
+        delete memMap[key];
+      }
+    }
+  }
+};
+
+module.exports = LocalDatastoreController;
diff --git a/src/LocalDatastoreController.react-native.js b/src/LocalDatastoreController.react-native.js
new file mode 100644
index 0000000..62c6f1d
--- /dev/null
+++ b/src/LocalDatastoreController.react-native.js
@@ -0,0 +1,56 @@
+/**
+ * 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.
+ *
+ * @flow
+ */
+
+const RNStorage = require('./StorageController.react-native');
+
+const LocalDatastoreController = {
+  async fromPinWithName(name: string): Promise {
+    const values = await RNStorage.getItemAsync(name);
+    if (!values) {
+      return Promise.resolve(null);
+    }
+    const objects = JSON.parse(values);
+    return Promise.resolve(objects);
+  },
+
+  async pinWithName(name: string, value: any): Promise {
+    try {
+      const values = JSON.stringify(value);
+      await RNStorage.setItemAsync(name, values);
+    } catch (e) {
+      // Quota exceeded, possibly due to Safari Private Browsing mode
+      console.log(e.message); // eslint-disable-line no-console
+    }
+    return Promise.resolve();
+  },
+
+  async unPinWithName(name: string): Promise {
+    await RNStorage.removeItemAsync(name);
+    return Promise.resolve();
+  },
+
+  async getAllContents(): Promise {
+    const LDS = {};
+    const keys = await RNStorage.getAllKeys();
+    for (let i = 0; i < keys.length; i += 1) {
+      const key = keys[i];
+      const value = await RNStorage.getItemAsync(key);
+      LDS[key] = JSON.parse(value);
+    }
+    return Promise.resolve(LDS);
+  },
+
+  clear(): Promise {
+    return RNStorage.clear();
+  }
+};
+
+module.exports = LocalDatastoreController;
diff --git a/src/OfflineQuery.js b/src/OfflineQuery.js
new file mode 100644
index 0000000..471a57d
--- /dev/null
+++ b/src/OfflineQuery.js
@@ -0,0 +1,354 @@
+var equalObjects = require('./equals').default;
+var decode = require('./decode').default;
+var ParseError = require('./ParseError').default;
+var ParsePolygon = require('./ParsePolygon').default;
+var ParseGeoPoint = require('./ParseGeoPoint').default;
+
+/**
+ * contains -- Determines if an object is contained in a list with special handling for Parse pointers.
+ */
+function contains(haystack, needle) {
+
+  if (needle && needle.__type && (needle.__type === 'Pointer' || needle.__type === 'Object')) {
+    for (const i in haystack) {
+      const ptr = haystack[i];
+      if (typeof ptr === 'string' && ptr === needle.objectId) {
+        return true;
+      }
+      if (ptr.className === needle.className && ptr.objectId === needle.objectId) {
+        return true;
+      }
+    }
+    return false;
+  }
+  return haystack.indexOf(needle) > -1;
+}
+
+function transformObject(object) {
+  if (object._toFullJSON) {
+    return object._toFullJSON();
+  }
+  return object;
+}
+/**
+ * matchesQuery -- Determines if an object would be returned by a Parse Query
+ * It's a lightweight, where-clause only implementation of a full query engine.
+ * Since we find queries that match objects, rather than objects that match
+ * queries, we can avoid building a full-blown query tool.
+ */
+function matchesQuery(className, object, objects, query) {
+  if (object.className !== className) {
+    return false;
+  }
+  let obj = object;
+  let q = query;
+  if (object.toJSON) {
+    obj = object.toJSON();
+  }
+  if (query.toJSON) {
+    q = query.toJSON().where;
+  }
+  obj.className = className;
+  for (var field in q) {
+    if (!matchesKeyConstraints(className, obj, objects, field, q[field])) {
+      return false;
+    }
+  }
+  return true;
+}
+
+function equalObjectsGeneric(obj, compareTo, eqlFn) {
+  if (Array.isArray(obj)) {
+    for (let i = 0; i < obj.length; i++) {
+      if (eqlFn(obj[i], compareTo)) {
+        return true;
+      }
+    }
+    return false;
+  }
+  return eqlFn(obj, compareTo);
+}
+
+/**
+ * Determines whether an object matches a single key's constraints
+ */
+function matchesKeyConstraints(className, object, objects, key, constraints) {
+  if (constraints === null) {
+    return false;
+  }
+  if (key.indexOf('.') >= 0) {
+    // Key references a subobject
+    var keyComponents = key.split('.');
+    var subObjectKey = keyComponents[0];
+    var keyRemainder = keyComponents.slice(1).join('.');
+    return matchesKeyConstraints(className, object[subObjectKey] || {}, objects, keyRemainder, constraints);
+  }
+  var i;
+  if (key === '$or') {
+    for (i = 0; i < constraints.length; i++) {
+      if (matchesQuery(className, object, objects, constraints[i])) {
+        return true;
+      }
+    }
+    return false;
+  }
+  if (key === '$and') {
+    for (i = 0; i < constraints.length; i++) {
+      if (!matchesQuery(className, object, objects, constraints[i])) {
+        return false;
+      }
+    }
+    return true;
+  }
+  if (key === '$nor') {
+    for (i = 0; i < constraints.length; i++) {
+      if (matchesQuery(className, object, objects, constraints[i])) {
+        return false;
+      }
+    }
+    return true;
+  }
+  if (key === '$relatedTo') {
+    // Bail! We can't handle relational queries locally
+    return false;
+  }
+  if (!(/^[A-Za-z][0-9A-Za-z_]*$/).test(key)) {
+    throw new ParseError(ParseError.INVALID_KEY_NAME, `Invalid Key: ${key}`);
+  }
+  // Equality (or Array contains) cases
+  if (typeof constraints !== 'object') {
+    if (Array.isArray(object[key])) {
+      return object[key].indexOf(constraints) > -1;
+    }
+    return object[key] === constraints;
+  }
+  var compareTo;
+  if (constraints.__type) {
+    if (constraints.__type === 'Pointer') {
+      return equalObjectsGeneric(object[key], constraints, function (obj, ptr) {
+        return typeof obj !== 'undefined' && ptr.className === obj.className && ptr.objectId === obj.objectId;
+      });
+    }
+    return equalObjectsGeneric(decode(object[key]), decode(constraints), equalObjects);
+  }
+  // More complex cases
+  for (var condition in constraints) {
+    compareTo = constraints[condition];
+    if (compareTo.__type) {
+      compareTo = decode(compareTo);
+    }
+    if (toString.call(compareTo) === '[object Date]') {
+      object[key] = new Date(object[key]);
+    }
+    switch (condition) {
+    case '$lt':
+      if (object[key] >= compareTo) {
+        return false;
+      }
+      break;
+    case '$lte':
+      if (object[key] > compareTo) {
+        return false;
+      }
+      break;
+    case '$gt':
+      if (object[key] <= compareTo) {
+        return false;
+      }
+      break;
+    case '$gte':
+      if (object[key] < compareTo) {
+        return false;
+      }
+      break;
+    case '$ne':
+      if (equalObjects(object[key], compareTo)) {
+        return false;
+      }
+      break;
+    case '$in':
+      if (!contains(compareTo, object[key])) {
+        return false;
+      }
+      break;
+    case '$nin':
+      if (contains(compareTo, object[key])) {
+        return false;
+      }
+      break;
+    case '$all':
+      for (i = 0; i < compareTo.length; i++) {
+        if (object[key].indexOf(compareTo[i]) < 0) {
+          return false;
+        }
+      }
+      break;
+    case '$exists':
+    {
+      const propertyExists = typeof object[key] !== 'undefined';
+      const existenceIsRequired = constraints['$exists'];
+      if (typeof constraints['$exists'] !== 'boolean') {
+        // The SDK will never submit a non-boolean for $exists, but if someone
+        // tries to submit a non-boolean for $exits outside the SDKs, just ignore it.
+        break;
+      }
+      if (!propertyExists && existenceIsRequired || propertyExists && !existenceIsRequired) {
+        return false;
+      }
+      break;
+    }
+    case '$regex': {
+      if (typeof compareTo === 'object') {
+        return compareTo.test(object[key]);
+      }
+      // JS doesn't support perl-style escaping
+      var expString = '';
+      var escapeEnd = -2;
+      var escapeStart = compareTo.indexOf('\\Q');
+      while (escapeStart > -1) {
+        // Add the unescaped portion
+        expString += compareTo.substring(escapeEnd + 2, escapeStart);
+        escapeEnd = compareTo.indexOf('\\E', escapeStart);
+        if (escapeEnd > -1) {
+          expString += compareTo.substring(escapeStart + 2, escapeEnd).replace(/\\\\\\\\E/g, '\\E').replace(/\W/g, '\\$&');
+        }
+
+        escapeStart = compareTo.indexOf('\\Q', escapeEnd);
+      }
+      expString += compareTo.substring(Math.max(escapeStart, escapeEnd + 2));
+      let modifiers = constraints.$options || '';
+      modifiers = modifiers.replace('x', '').replace('s', '')
+      // Parse Server / Mongo support x and s modifiers but JS RegExp doesn't
+      var exp = new RegExp(expString, modifiers);
+      if (!exp.test(object[key])) {
+        return false;
+      }
+      break;
+    }
+    case '$nearSphere':
+      if (!compareTo || !object[key]) {
+        return false;
+      }
+      var distance = compareTo.radiansTo(object[key]);
+      var max = constraints.$maxDistance || Infinity;
+      return distance <= max;
+    case '$within':
+      if (!compareTo || !object[key]) {
+        return false;
+      }
+      var southWest = compareTo.$box[0];
+      var northEast = compareTo.$box[1];
+      if (southWest.latitude > northEast.latitude || southWest.longitude > northEast.longitude) {
+        // Invalid box, crosses the date line
+        return false;
+      }
+      return object[key].latitude > southWest.latitude && object[key].latitude < northEast.latitude && object[key].longitude > southWest.longitude && object[key].longitude < northEast.longitude;
+    case '$options':
+      // Not a query type, but a way to add options to $regex. Ignore and
+      // avoid the default
+      break;
+    case '$maxDistance':
+      // Not a query type, but a way to add a cap to $nearSphere. Ignore and
+      // avoid the default
+      break;
+    case '$select': {
+      const subQueryObjects = objects.filter((obj, index, arr) => {
+        return matchesQuery(compareTo.query.className, obj, arr, compareTo.query.where);
+      });
+      for (let i = 0; i < subQueryObjects.length; i += 1) {
+        const subObject = transformObject(subQueryObjects[i]);
+        return equalObjects(object[key], subObject[compareTo.key]);
+      }
+      return false;
+    }
+    case '$dontSelect': {
+      const subQueryObjects = objects.filter((obj, index, arr) => {
+        return matchesQuery(compareTo.query.className, obj, arr, compareTo.query.where);
+      });
+      for (let i = 0; i < subQueryObjects.length; i += 1) {
+        const subObject = transformObject(subQueryObjects[i]);
+        return !equalObjects(object[key], subObject[compareTo.key]);
+      }
+      return false;
+    }
+    case '$inQuery': {
+      const subQueryObjects = objects.filter((obj, index, arr) => {
+        return matchesQuery(compareTo.className, obj, arr, compareTo.where);
+      });
+
+      for (let i = 0; i < subQueryObjects.length; i += 1) {
+        const subObject = transformObject(subQueryObjects[i]);
+        if (object[key].className === subObject.className &&
+            object[key].objectId === subObject.objectId) {
+          return true;
+        }
+      }
+      return false;
+    }
+    case '$notInQuery': {
+      const subQueryObjects = objects.filter((obj, index, arr) => {
+        return matchesQuery(compareTo.className, obj, arr, compareTo.where);
+      });
+
+      for (let i = 0; i < subQueryObjects.length; i += 1) {
+        const subObject = transformObject(subQueryObjects[i]);
+        if (object[key].className === subObject.className &&
+            object[key].objectId === subObject.objectId) {
+          return false;
+        }
+      }
+      return true;
+    }
+    case '$containedBy': {
+      for (const value of object[key]) {
+        if (!contains(compareTo, value)) {
+          return false;
+        }
+      }
+      return true;
+    }
+    case '$geoWithin': {
+      const points = compareTo.$polygon.map((geoPoint) => [geoPoint.latitude, geoPoint.longitude]);
+      const polygon = new ParsePolygon(points);
+      return polygon.containsPoint(object[key]);
+    }
+    case '$geoIntersects': {
+      const polygon = new ParsePolygon(object[key].coordinates);
+      const point = new ParseGeoPoint(compareTo.$point);
+      return polygon.containsPoint(point);
+    }
+    default:
+      return false;
+    }
+  }
+  return true;
+}
+
+function validateQuery(query: any) {
+  let q = query;
+
+  if (query.toJSON) {
+    q = query.toJSON().where;
+  }
+  const specialQuerykeys = ['$and', '$or', '$nor', '_rperm', '_wperm', '_perishable_token', '_email_verify_token', '_email_verify_token_expires_at', '_account_lockout_expires_at', '_failed_login_count'];
+
+  Object.keys(q).forEach(key => {
+    if (q && q[key] && q[key].$regex) {
+      if (typeof q[key].$options === 'string') {
+        if (!q[key].$options.match(/^[imxs]+$/)) {
+          throw new ParseError(ParseError.INVALID_QUERY, `Bad $options value for query: ${q[key].$options}`);
+        }
+      }
+    }
+    if (specialQuerykeys.indexOf(key) < 0 && !key.match(/^[a-zA-Z][a-zA-Z0-9_\.]*$/)) {
+      throw new ParseError(ParseError.INVALID_KEY_NAME, `Invalid key name: ${key}`);
+    }
+  });
+}
+
+var OfflineQuery = {
+  matchesQuery: matchesQuery,
+  validateQuery: validateQuery,
+};
+
+module.exports = OfflineQuery;
diff --git a/src/Parse.js b/src/Parse.js
index 7d2c0af..3df5361 100644
--- a/src/Parse.js
+++ b/src/Parse.js
@@ -58,6 +58,16 @@ var Parse = {
    */
   setAsyncStorage(storage: any) {
     CoreManager.setAsyncStorage(storage);
+  },
+
+  /**
+   * Call this method to set your LocalDatastoreStorage engine
+   * If using React-Native use {@link Parse.setAsyncStorage Parse.setAsyncStorage()}
+   * @param {LocalDatastoreController} controller a data storage.
+   * @static
+   */
+  setLocalDatastoreController(controller: any) {
+    CoreManager.setLocalDatastoreController(controller);
   }
 };
 
@@ -143,6 +153,7 @@ Parse.File = require('./ParseFile').default;
 Parse.GeoPoint = require('./ParseGeoPoint').default;
 Parse.Polygon = require('./ParsePolygon').default;
 Parse.Installation = require('./ParseInstallation').default;
+Parse.LocalDatastore = require('./LocalDatastore');
 Parse.Object = require('./ParseObject').default;
 Parse.Op = {
   Set: ParseOp.SetOp,
@@ -180,7 +191,40 @@ Parse._encode = function(value, _, disallowObjects) {
 Parse._getInstallationId = function() {
   return CoreManager.getInstallationController().currentInstallationId();
 }
-
+/**
+ * Enable pinning in your application.
+ * This must be called before your application can use pinning.
+ *
+ * @static
+ */
+Parse.enableLocalDatastore = function() {
+  Parse.LocalDatastore.isEnabled = true;
+}
+/**
+ * Flag that indicates whether Local Datastore is enabled.
+ *
+ * @static
+ */
+Parse.isLocalDatastoreEnabled = function() {
+  return Parse.LocalDatastore.isEnabled;
+}
+/**
+ * Gets all contents from Local Datastore
+ *
+ * <pre>
+ * await Parse.dumpLocalDatastore();
+ * </pre>
+ *
+ * @static
+ */
+Parse.dumpLocalDatastore = function() {
+  if (!Parse.LocalDatastore.isEnabled) {
+    console.log('Parse.enableLocalDatastore() must be called first'); // eslint-disable-line no-console
+    return Promise.resolve({});
+  } else {
+    return Parse.LocalDatastore._getAllContents();
+  }
+}
 CoreManager.setInstallationController(InstallationController);
 CoreManager.setRESTController(RESTController);
 
diff --git a/src/ParseObject.js b/src/ParseObject.js
index 614a051..c4e1303 100644
--- a/src/ParseObject.js
+++ b/src/ParseObject.js
@@ -1168,6 +1168,93 @@ class ParseObject {
     );
   }
 
+  /**
+   * Asynchronously stores the object and every object it points to in the local datastore,
+   * recursively, using a default pin name: _default.
+   *
+   * If those other objects have not been fetched from Parse, they will not be stored.
+   * However, if they have changed data, all the changes will be retained.
+   *
+   * <pre>
+   * await object.pin();
+   * </pre>
+   *
+   * To retrieve object:
+   * <code>query.fromLocalDatastore()</code> or <code>query.fromPin()</code>
+   */
+  pin(): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    return ParseObject.pinAllWithName(localDatastore.DEFAULT_PIN, [this]);
+  }
+
+  /**
+   * Asynchronously removes the object and every object it points to in the local datastore,
+   * recursively, using a default pin name: _default.
+   *
+   * <pre>
+   * await object.unPin();
+   * </pre>
+   */
+  unPin(): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    return ParseObject.unPinAllWithName(localDatastore.DEFAULT_PIN, [this]);
+  }
+
+  /**
+   * Asynchronously stores the objects and every object they point to in the local datastore, recursively.
+   *
+   * If those other objects have not been fetched from Parse, they will not be stored.
+   * However, if they have changed data, all the changes will be retained.
+   *
+   * <pre>
+   * await object.pinWithName(name);
+   * </pre>
+   *
+   * To retrieve object:
+   * <code>query.fromLocalDatastore()</code> or <code>query.fromPinWithName(name)</code>
+   *
+   * @param {String} name Name of Pin.
+   */
+  pinWithName(name: string): Promise {
+    return ParseObject.pinAllWithName(name, [this]);
+  }
+
+  /**
+   * Asynchronously removes the object and every object it points to in the local datastore, recursively.
+   *
+   * <pre>
+   * await object.unPinWithName(name);
+   * </pre>
+   */
+  unPinWithName(name: string): Promise {
+    return ParseObject.unPinAllWithName(name, [this]);
+  }
+
+  /**
+   * Asynchronously loads data from the local datastore into this object.
+   *
+   * <pre>
+   * await object.fetchFromLocalDatastore();
+   * </pre>
+   *
+   * You can create an unfetched pointer with <code>Parse.Object.createWithoutData()</code>
+   * and then call <code>fetchFromLocalDatastore()</code> on it.
+   */
+  async fetchFromLocalDatastore(): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    if (localDatastore.checkIfEnabled()) {
+      const objectKey = localDatastore.getKeyForObject(this);
+      const pinned = await localDatastore._serializeObject(objectKey);
+      if (!pinned) {
+        throw new Error('Cannot fetch an unsaved ParseObject');
+      }
+      const result = ParseObject.fromJSON(pinned);
+      this._finishFetch(result.toJSON());
+
+      return Promise.resolve(this);
+    }
+  }
+
   /** Static methods **/
 
   static _clearAllState() {
@@ -1642,10 +1729,133 @@ class ParseObject {
     singleInstance = false;
     CoreManager.setObjectStateController(UniqueInstanceStateController);
   }
+
+  /**
+   * Asynchronously stores the objects and every object they point to in the local datastore,
+   * recursively, using a default pin name: _default.
+   *
+   * If those other objects have not been fetched from Parse, they will not be stored.
+   * However, if they have changed data, all the changes will be retained.
+   *
+   * <pre>
+   * await Parse.Object.pinAll([...]);
+   * </pre>
+   *
+   * To retrieve object:
+   * <code>query.fromLocalDatastore()</code> or <code>query.fromPin()</code>
+   *
+   * @param {Array} objects A list of <code>Parse.Object</code>.
+   * @static
+   */
+  static pinAll(objects: Array<ParseObject>): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    if (localDatastore.checkIfEnabled()) {
+      return ParseObject.pinAllWithName(localDatastore.DEFAULT_PIN, objects);
+    }
+  }
+
+  /**
+   * Asynchronously stores the objects and every object they point to in the local datastore, recursively.
+   *
+   * If those other objects have not been fetched from Parse, they will not be stored.
+   * However, if they have changed data, all the changes will be retained.
+   *
+   * <pre>
+   * await Parse.Object.pinAllWithName(name, [obj1, obj2, ...]);
+   * </pre>
+   *
+   * To retrieve object:
+   * <code>query.fromLocalDatastore()</code> or <code>query.fromPinWithName(name)</code>
+   *
+   * @param {String} name Name of Pin.
+   * @param {Array} objects A list of <code>Parse.Object</code>.
+   * @static
+   */
+  static async pinAllWithName(name: string, objects: Array<ParseObject>): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    if (localDatastore.checkIfEnabled()) {
+      for (const object of objects) {
+        await localDatastore._handlePinWithName(name, object);
+      }
+    }
+  }
+
+  /**
+   * Asynchronously removes the objects and every object they point to in the local datastore,
+   * recursively, using a default pin name: _default.
+   *
+   * <pre>
+   * await Parse.Object.unPinAll([...]);
+   * </pre>
+   *
+   * @param {Array} objects A list of <code>Parse.Object</code>.
+   * @static
+   */
+  static unPinAll(objects: Array<ParseObject>): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    if (localDatastore.checkIfEnabled()) {
+      return ParseObject.unPinAllWithName(localDatastore.DEFAULT_PIN, objects);
+    }
+  }
+
+  /**
+   * Asynchronously removes the objects and every object they point to in the local datastore, recursively.
+   *
+   * <pre>
+   * await Parse.Object.unPinAllWithName(name, [obj1, obj2, ...]);
+   * </pre>
+   *
+   * @param {String} name Name of Pin.
+   * @param {Array} objects A list of <code>Parse.Object</code>.
+   * @static
+   */
+  static async unPinAllWithName(name: string, objects: Array<ParseObject>): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    if (localDatastore.checkIfEnabled()) {
+      for (const object of objects) {
+        await localDatastore._handleUnPinWithName(name, object);
+      }
+    }
+  }
+
+  /**
+   * Asynchronously removes all objects in the local datastore using a default pin name: _default.
+   *
+   * <pre>
+   * await Parse.Object.unPinAllObjects();
+   * </pre>
+   *
+   * @static
+   */
+  static unPinAllObjects(): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    if (localDatastore.checkIfEnabled()) {
+      return localDatastore.unPinWithName(localDatastore.DEFAULT_PIN);
+    }
+  }
+
+  /**
+   * Asynchronously removes all objects with the specified pin name.
+   * Deletes the pin name also.
+   *
+   * <pre>
+   * await Parse.Object.unPinAllObjectsWithName(name);
+   * </pre>
+   *
+   * @param {String} name Name of Pin.
+   * @static
+   */
+  static unPinAllObjectsWithName(name: string): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
+    if (localDatastore.checkIfEnabled()) {
+      return localDatastore.unPinWithName(localDatastore.PIN_PREFIX + name);
+    }
+  }
 }
 
 var DefaultController = {
   fetch(target: ParseObject | Array<ParseObject>, forceFetch: boolean, options: RequestOptions): Promise {
+    const localDatastore = CoreManager.getLocalDatastore();
     if (Array.isArray(target)) {
       if (target.length < 1) {
         return Promise.resolve([]);
@@ -1689,7 +1899,7 @@ var DefaultController = {
         query.include(options.include);
       }
       query._limit = ids.length;
-      return query.find(options).then((objects) => {
+      return query.find(options).then(async (objects) => {
         var idMap = {};
         objects.forEach((o) => {
           idMap[o.id] = o;
@@ -1718,6 +1928,9 @@ var DefaultController = {
             }
           }
         }
+        for (const object of results) {
+          await localDatastore._updateObjectIfPinned(object);
+        }
         return Promise.resolve(results);
       });
     } else {
@@ -1731,19 +1944,21 @@ var DefaultController = {
         'classes/' + target.className + '/' + target._getId(),
         params,
         options
-      ).then((response) => {
+      ).then(async (response) => {
         if (target instanceof ParseObject) {
           target._clearPendingOps();
           target._clearServerData();
           target._finishFetch(response);
         }
+        await localDatastore._updateObjectIfPinned(target);
         return target;
       });
     }
   },
 
-  destroy(target: ParseObject | Array<ParseObject>, options: RequestOptions): Promise {
+  async destroy(target: ParseObject | Array<ParseObject>, options: RequestOptions): Promise {
     const batchSize = (options && options.batchSize) ? options.batchSize : DEFAULT_BATCH_SIZE;
+    const localDatastore = CoreManager.getLocalDatastore();
 
     var RESTController = CoreManager.getRESTController();
     if (Array.isArray(target)) {
@@ -1790,12 +2005,15 @@ var DefaultController = {
           });
         });
       });
-      return deleteCompleted.then(() => {
+      return deleteCompleted.then(async () => {
         if (errors.length) {
           var aggregate = new ParseError(ParseError.AGGREGATE_ERROR);
           aggregate.errors = errors;
           return Promise.reject(aggregate);
         }
+        for (const object of target) {
+          await localDatastore._destroyObjectIfPinned(object);
+        }
         return Promise.resolve(target);
       });
     } else if (target instanceof ParseObject) {
@@ -1804,15 +2022,19 @@ var DefaultController = {
         'classes/' + target.className + '/' + target._getId(),
         {},
         options
-      ).then(() => {
+      ).then(async () => {
+        await localDatastore._destroyObjectIfPinned(target);
         return Promise.resolve(target);
       });
     }
+    await localDatastore._destroyObjectIfPinned(target);
     return Promise.resolve(target);
   },
 
   save(target: ParseObject | Array<ParseObject | ParseFile>, options: RequestOptions) {
     const batchSize = (options && options.batchSize) ? options.batchSize : DEFAULT_BATCH_SIZE;
+    const localDatastore = CoreManager.getLocalDatastore();
+    const mapIdForPin = {};
 
     var RESTController = CoreManager.getRESTController();
     var stateController = CoreManager.getObjectStateController();
@@ -1883,6 +2105,8 @@ var DefaultController = {
               ready.resolve();
               return batchReturned.then((responses, status) => {
                 if (responses[index].hasOwnProperty('success')) {
+                  const objectId = responses[index].success.objectId;
+                  mapIdForPin[objectId] = obj._localId;
                   obj._handleSaveResponse(responses[index].success, status);
                 } else {
                   if (!objectError && responses[index].hasOwnProperty('error')) {
@@ -1915,16 +2139,21 @@ var DefaultController = {
           });
 
           return when(batchTasks);
-        }).then(() => {
+        }).then(async () => {
           if (objectError) {
             return Promise.reject(objectError);
           }
+          for (const object of target) {
+            await localDatastore._updateLocalIdForObject(mapIdForPin[object.id], object);
+            await localDatastore._updateObjectIfPinned(object);
+          }
           return Promise.resolve(target);
         });
       });
 
     } else if (target instanceof ParseObject) {
       // copying target lets Flow guarantee the pointer isn't modified elsewhere
+      const localId = target._localId;
       var targetCopy = target;
       var task = function() {
         var params = targetCopy._getSaveParams();
@@ -1942,7 +2171,9 @@ var DefaultController = {
       }
 
       stateController.pushPendingState(target._getStateIdentifier());
-      return stateController.enqueueTask(target._getStateIdentifier(), task).then(() => {
+      return stateController.enqueueTask(target._getStateIdentifier(), task).then(async () => {
+        await localDatastore._updateLocalIdForObject(localId, target);
+        await localDatastore._updateObjectIfPinned(target);
         return target;
       }, (error) => {
         return Promise.reject(error);
diff --git a/src/ParseQuery.js b/src/ParseQuery.js
index b55d6da..3bf4ce8 100644
--- a/src/ParseQuery.js
+++ b/src/ParseQuery.js
@@ -15,6 +15,7 @@ import { continueWhile } from './promiseUtils';
 import ParseError from './ParseError';
 import ParseGeoPoint from './ParseGeoPoint';
 import ParseObject from './ParseObject';
+import OfflineQuery from './OfflineQuery';
 
 import type { RequestOptions, FullOptions } from './RESTController';
 
@@ -132,6 +133,37 @@ function copyMissingDataWithMask(src, dest, mask, copyThisLevel){
   }
 }
 
+function handleOfflineSort(a, b, sorts) {
+  let order = sorts[0];
+  const operator = order.slice(0, 1);
+  const isDescending = operator === '-';
+  if (isDescending) {
+    order = order.substring(1);
+  }
+  if (order === '_created_at') {
+    order = 'createdAt';
+  }
+  if (order === '_updated_at') {
+    order = 'updatedAt';
+  }
+  if (!(/^[A-Za-z][0-9A-Za-z_]*$/).test(order) || order === 'password') {
+    throw new ParseError(ParseError.INVALID_KEY_NAME, `Invalid Key: ${order}`);
+  }
+  const field1 = a.get(order);
+  const field2 = b.get(order);
+
+  if (field1 < field2) {
+    return isDescending ? 1 : -1;
+  }
+  if (field1 > field2) {
+    return isDescending ? -1 : 1;
+  }
+  if (sorts.length > 1) {
+    const remainingSorts = sorts.slice(1);
+    return handleOfflineSort(a, b, remainingSorts);
+  }
+  return 0;
+}
 /**
  * Creates a new parse Parse.Query for the given Parse.Object subclass.
  *
@@ -186,6 +218,8 @@ class ParseQuery {
   _limit: number;
   _skip: number;
   _order: Array<string>;
+  _queriesLocalDatastore: boolean;
+  _localDatastorePinName: any;
   _extraOptions: { [key: string]: mixed };
 
   /**
@@ -217,6 +251,8 @@ class ParseQuery {
     this._include = [];
     this._limit = -1; // negative limit is not sent in the server request
     this._skip = 0;
+    this._queriesLocalDatastore = false;
+    this._localDatastorePinName = null;
     this._extraOptions = {};
   }
 
@@ -280,6 +316,53 @@ class ParseQuery {
     return '^' + quote(string);
   }
 
+  async _handleOfflineQuery(params: any) {
+    OfflineQuery.validateQuery(this);
+    const localDatastore = CoreManager.getLocalDatastore();
+    const objects = await localDatastore._serializeObjectsFromPinName(this._localDatastorePinName);
+    let results = objects.map((json, index, arr) => {
+      const object = ParseObject.fromJSON(json, false);
+
+      if (!OfflineQuery.matchesQuery(this.className, object, arr, this)) {
+        return null;
+      }
+      return object;
+    }).filter((object) => object !== null);
+    if (params.keys) {
+      let keys = params.keys.split(',');
+      const alwaysSelectedKeys = ['className', 'objectId', 'createdAt', 'updatedAt', 'ACL'];
+      keys = keys.concat(alwaysSelectedKeys);
+      results = results.map((object) => {
+        const json = object._toFullJSON();
+        Object.keys(json).forEach((key) => {
+          if (!keys.includes(key)) {
+            delete json[key];
+          }
+        });
+        return ParseObject.fromJSON(json, false);
+      });
+    }
+    if (params.order) {
+      const sorts = params.order.split(',');
+      results.sort((a, b) => {
+        return handleOfflineSort(a, b, sorts);
+      });
+    }
+    if (params.skip) {
+      if (params.skip >= results.length) {
+        results = [];
+      } else {
+        results = results.splice(params.skip, results.length);
+      }
+    }
+    let limit = results.length;
+    if (params.limit !== 0 && params.limit < results.length) {
+      limit = params.limit;
+    }
+    results = results.splice(0, limit);
+    return results;
+  }
+
   /**
    * Returns a JSON representation of this query.
    * @return {Object} The JSON representation of the query.
@@ -453,6 +536,9 @@ class ParseQuery {
 
     const select = this._select;
 
+    if (this._queriesLocalDatastore) {
+      return this._handleOfflineQuery(this.toJSON());
+    }
     return controller.find(
       this.className,
       this.toJSON(),
@@ -627,6 +713,15 @@ class ParseQuery {
 
     var select = this._select;
 
+    if (this._queriesLocalDatastore) {
+      return this._handleOfflineQuery(params).then((objects) => {
+        if (!objects[0]) {
+          return undefined;
+        }
+        return objects[0];
+      });
+    }
+
     return controller.find(
       this.className,
       params,
@@ -1444,6 +1539,32 @@ class ParseQuery {
     query._norQuery(queries);
     return query;
   }
+
+  /**
+   * Changes the source of this query to all pinned objects.
+   */
+  fromLocalDatastore() {
+    this.fromPinWithName(null);
+  }
+
+  /**
+   * Changes the source of this query to the default group of pinned objects.
+   */
+  fromPin() {
+    const localDatastore = CoreManager.getLocalDatastore();
+    this.fromPinWithName(localDatastore.DEFAULT_PIN);
+  }
+
+  /**
+   * Changes the source of this query to a specific group of pinned objects.
+   */
+  fromPinWithName(name: string) {
+    const localDatastore = CoreManager.getLocalDatastore();
+    if (localDatastore.checkIfEnabled()) {
+      this._queriesLocalDatastore = true;
+      this._localDatastorePinName = name;
+    }
+  }
 }
 
 var DefaultController = {
diff --git a/src/StorageController.react-native.js b/src/StorageController.react-native.js
index 052ce2b..029c1cf 100644
--- a/src/StorageController.react-native.js
+++ b/src/StorageController.react-native.js
@@ -54,8 +54,20 @@ var StorageController = {
     });
   },
 
+  getAllKeys(): Promise {
+    return new Promise((resolve, reject) => {
+      this.getAsyncStorage().getAllKeys(function(err, keys) {
+        if (err) {
+          reject(err);
+        } else {
+          resolve(keys);
+        }
+      });
+    });
+  },
+
   clear() {
-    this.getAsyncStorage().clear();
+    return this.getAsyncStorage().clear();
   }
 };
 
diff --git a/src/equals.js b/src/equals.js
index 68b32ec..96860d0 100644
--- a/src/equals.js
+++ b/src/equals.js
@@ -6,6 +6,7 @@
  * 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.
  */
+const toString = Object.prototype.toString;
 
 import ParseACL from './ParseACL';
 import ParseFile from './ParseFile';
@@ -13,6 +14,12 @@ import ParseGeoPoint from './ParseGeoPoint';
 import ParseObject from './ParseObject';
 
 export default function equals(a, b) {
+  if (toString.call(a) === '[object Date]' || toString.call(b) === '[object Date]') {
+    const dateA = new Date(a);
+    const dateB = new Date(b);
+    return (+dateA === +dateB);
+  }
+
   if (typeof a !== typeof b) {
     return false;
   }
@@ -43,7 +50,11 @@ export default function equals(a, b) {
       (a instanceof ParseObject)) {
     return a.equals(b);
   }
-
+  if (b instanceof ParseObject) {
+    if (a.__type === 'Object' || a.__type === 'Pointer') {
+      return a.objectId === b.id && a.className === b.className;
+    }
+  }
   if (Object.keys(a).length !== Object.keys(b).length) {
     return false;
   }
diff --git a/src/interfaces/react-native.js b/src/interfaces/react-native.js
index c15ab02..346d35c 100644
--- a/src/interfaces/react-native.js
+++ b/src/interfaces/react-native.js
@@ -15,6 +15,7 @@ declare module "react-native" {
     static getItem(path: string, cb: (err: string, value: string) => void): void;
     static setItem(path: string, value: string, cb: (err: string, value: string) => void): void;
     static removeItem(path: string, cb: (err: string, value: string) => void): void;
+    static getAllKeys(cb: (err: string, keys: Array<string>) => void): void;
     static clear(): void;
   }
 }


Test patch:
diff --git a/integration/test/ParseLocalDatastoreTest.js b/integration/test/ParseLocalDatastoreTest.js
new file mode 100644
index 0000000..f81af6c
--- /dev/null
+++ b/integration/test/ParseLocalDatastoreTest.js
@@ -0,0 +1,2406 @@
+'use strict';
+
+const assert = require('assert');
+const clear = require('./clear');
+const Parse = require('../../node');
+const TestObject = Parse.Object.extend('TestObject');
+const Item = Parse.Object.extend('Item');
+
+global.localStorage = require('./mockLocalStorage');
+const mockRNStorage = require('./mockRNStorage');
+
+const DEFAULT_PIN = Parse.LocalDatastore.DEFAULT_PIN;
+const PIN_PREFIX = Parse.LocalDatastore.PIN_PREFIX;
+
+function runTest(controller) {
+  describe(`Parse Object Pinning (${controller.name})`, () => {
+    beforeEach(() => {
+      const StorageController = require(controller.file);
+      Parse.CoreManager.setAsyncStorage(mockRNStorage);
+      Parse.CoreManager.setLocalDatastoreController(StorageController);
+      Parse.enableLocalDatastore();
+      Parse.LocalDatastore._clear();
+    });
+
+    it(`${controller.name} can pin (unsaved)`, async () => {
+      const object = new TestObject();
+      await object.pin();
+      // Since object not saved check localId
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${object.className}_${object._localId}`]);
+      assert.deepEqual(localDatastore[`${object.className}_${object._localId}`], object._toFullJSON());
+      await object.save();
+      // Check if localDatastore updated localId to objectId
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${object.className}_${object.id}`]);
+      assert.deepEqual(localDatastore[`${object.className}_${object.id}`], object._toFullJSON());
+    });
+
+    it(`${controller.name} cannot pin unsaved pointer`, async () => {
+      try {
+        const object = new TestObject();
+        const pointer = new Item();
+        object.set('child', pointer);
+        await object.pin();
+      } catch (e) {
+        assert.equal(e.message, 'Cannot create a pointer to an unsaved ParseObject');
+      }
+    });
+
+    it(`${controller.name} can pin (saved)`, async () => {
+      const object = new TestObject();
+      object.set('field', 'test');
+      await object.save();
+      await object.pin();
+      const localDatastore = await Parse.LocalDatastore._getAllContents();
+      const cachedObject = localDatastore[`${object.className}_${object.id}`];
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${object.className}_${object.id}`]);
+      assert.deepEqual(localDatastore[`${object.className}_${object.id}`], object._toFullJSON());
+      assert.equal(cachedObject.objectId, object.id);
+      assert.equal(cachedObject.field, 'test');
+    });
+
+    it(`${controller.name} can pin (twice saved)`, async () => {
+      const object = new TestObject();
+      object.set('field', 'test');
+      await object.save();
+
+      await object.pin();
+      object.set('field', 'new info');
+      await object.save();
+
+      const localDatastore = await Parse.LocalDatastore._getAllContents();
+      const cachedObject = localDatastore[`${object.className}_${object.id}`];
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${object.className}_${object.id}`]);
+      assert.deepEqual(localDatastore[`${object.className}_${object.id}`], object._toFullJSON());
+      assert.equal(cachedObject.objectId, object.id);
+      assert.equal(cachedObject.field, 'new info');
+    });
+
+    it(`${controller.name} can pin (recursive)`, async () => {
+      const parent = new TestObject();
+      const child = new Item();
+      const grandchild = new Item();
+      child.set('grandchild', grandchild);
+      parent.set('field', 'test');
+      parent.set('child', child);
+      await Parse.Object.saveAll([parent, child, grandchild]);
+      await parent.pin();
+      const localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${parent.className}_${parent.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${child.className}_${child.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${grandchild.className}_${grandchild.id}`), true);
+      assert.deepEqual(localDatastore[`${parent.className}_${parent.id}`], parent._toFullJSON());
+      assert.deepEqual(localDatastore[`${child.className}_${child.id}`], child._toFullJSON());
+      assert.deepEqual(localDatastore[`${grandchild.className}_${grandchild.id}`], grandchild._toFullJSON());
+    });
+
+    it(`${controller.name} can pinAll (unsaved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1.id}`, `${obj2.className}_${obj2.id}`, `${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2.id}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can pinAll (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.pinAll(objects);
+      const localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1.id}`, `${obj2.className}_${obj2.id}`, `${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2.id}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can pinAllWithName (unsaved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAllWithName('test_pin', objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_pin'], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_pin'], [`${obj1.className}_${obj1.id}`, `${obj2.className}_${obj2.id}`, `${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2.id}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can pinAllWithName (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.pinAllWithName('test_pin', objects);
+      const localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_pin'], [`${obj1.className}_${obj1.id}`, `${obj2.className}_${obj2.id}`, `${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2.id}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPin on destroy`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      await obj1.pin();
+      await obj2.pin();
+      await obj1.pinWithName('test_pin');
+      await obj2.pinWithName('test_pin');
+
+      await Parse.Object.saveAll([obj1, obj2]);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 4);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj1.className}_${obj1.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj2.className}_${obj2.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj1.className}_${obj1.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj2.className}_${obj2.id}`), true);
+      assert(localDatastore[`${obj1.className}_${obj1.id}`]);
+      assert(localDatastore[`${obj2.className}_${obj2.id}`]);
+
+      await obj1.destroy();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 3);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj2.className}_${obj2.id}`]);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_pin'], [`${obj2.className}_${obj2.id}`]);
+      assert(localDatastore[`${obj2.className}_${obj2.id}`]);
+    });
+
+    it(`${controller.name} can unPin on destroyAll`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+      await Parse.Object.pinAll(objects);
+      await Parse.Object.pinAllWithName('test_pin', objects);
+      await Parse.Object.saveAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 5);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj1.className}_${obj1.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj2.className}_${obj2.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj3.className}_${obj3.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj1.className}_${obj1.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj2.className}_${obj2.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj3.className}_${obj3.id}`), true);
+      assert(localDatastore[`${obj1.className}_${obj1.id}`]);
+      assert(localDatastore[`${obj2.className}_${obj2.id}`]);
+      assert(localDatastore[`${obj3.className}_${obj3.id}`]);
+
+      await Parse.Object.destroyAll([obj1, obj3]);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 3);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj2.className}_${obj2.id}`]);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_pin'], [`${obj2.className}_${obj2.id}`]);
+      assert(localDatastore[`${obj2.className}_${obj2.id}`]);
+    });
+
+    it(`${controller.name} can unPin with pinAll (unsaved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await obj2.unPin();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1.id}`, `${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPin with pinAll (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      await obj2.unPin();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1.id}`, `${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPin / unPinAll without pin (unsaved)`, async () => {
+      const obj1 = new TestObject();
+      await obj1.unPin();
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 0);
+
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      await Parse.Object.unPinAll([obj2, obj3]);
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 0);
+    });
+
+    it(`${controller.name} can unPin / unPinAll without pin (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const unPinObject = new TestObject();
+
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.unPinAll(objects);
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 0);
+
+      await unPinObject.save();
+      await unPinObject.unPin();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 0);
+    });
+
+    it(`${controller.name} can unPinAll (unsaved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.unPinAll([obj1, obj2]);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPinAll (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.unPinAll([obj1, obj2]);
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPinAllObjects (unsaved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.unPinAllObjects();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2.id}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPinAllObjects (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[DEFAULT_PIN], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      Parse.Object.unPinAllObjects();
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2.id}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPinAllWithName (unsaved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAllWithName('test_unpin', objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 4);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_unpin'], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.unPinAllWithName('test_unpin', [obj1, obj2]);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_unpin'], [`${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_unpin'], [`${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPinAllWithName (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAllWithName('test_unpin', objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_unpin'], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.unPinAllWithName('test_unpin', [obj1, obj2]);
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 2);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_unpin'], [`${obj3.className}_${obj3.id}`]);
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPinAllObjectsWithName (unsaved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAllWithName('test_unpin', objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 4);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_unpin'], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.unPinAllObjectsWithName('test_unpin');
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2.id}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPinAllObjectsWithName (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+
+      await Parse.Object.pinAllWithName('test_unpin', objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 4);
+      assert.deepEqual(localDatastore[PIN_PREFIX + 'test_unpin'], [`${obj1.className}_${obj1._localId}`, `${obj2.className}_${obj2._localId}`, `${obj3.className}_${obj3._localId}`]);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1._localId}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2._localId}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3._localId}`], obj3._toFullJSON());
+
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.unPinAllObjectsWithName('test_unpin');
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert.equal(Object.keys(localDatastore).length, 3);
+      assert.deepEqual(localDatastore[`${obj1.className}_${obj1.id}`], obj1._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj2.className}_${obj2.id}`], obj2._toFullJSON());
+      assert.deepEqual(localDatastore[`${obj3.className}_${obj3.id}`], obj3._toFullJSON());
+    });
+
+    it(`${controller.name} can unPin and save reference`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const objects = [obj1, obj2, obj3];
+      await Parse.Object.pinAll(objects);
+      await Parse.Object.pinAllWithName('test_pin', objects);
+      await Parse.Object.saveAll(objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 5);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj1.className}_${obj1.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj2.className}_${obj2.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj3.className}_${obj3.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj1.className}_${obj1.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj2.className}_${obj2.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj3.className}_${obj3.id}`), true);
+      assert(localDatastore[`${obj1.className}_${obj1.id}`]);
+      assert(localDatastore[`${obj2.className}_${obj2.id}`]);
+      assert(localDatastore[`${obj3.className}_${obj3.id}`]);
+
+      await obj1.unPin();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(localDatastore[`${obj1.className}_${obj1.id}`]);
+    });
+
+    it(`${controller.name} can unPin and save reference with children`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const obj3 = new TestObject();
+      const child = new TestObject();
+      obj1.set('child', child);
+      const objects = [obj1, obj2, obj3];
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+      await Parse.Object.pinAllWithName('test_pin', objects);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(Object.keys(localDatastore).length === 6);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj1.className}_${obj1.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj2.className}_${obj2.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${obj3.className}_${obj3.id}`), true);
+      assert.equal(localDatastore[DEFAULT_PIN].includes(`${child.className}_${child.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj1.className}_${obj1.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj2.className}_${obj2.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${obj3.className}_${obj3.id}`), true);
+      assert.equal(localDatastore[PIN_PREFIX + 'test_pin'].includes(`${child.className}_${child.id}`), true);
+      assert(localDatastore[`${obj1.className}_${obj1.id}`]);
+      assert(localDatastore[`${obj2.className}_${obj2.id}`]);
+      assert(localDatastore[`${obj3.className}_${obj3.id}`]);
+      assert(localDatastore[`${child.className}_${child.id}`]);
+
+      await obj1.unPin();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+      assert(localDatastore[`${obj1.className}_${obj1.id}`]);
+      assert(localDatastore[`${child.className}_${child.id}`]);
+    });
+
+    it(`${controller.name} cannot fetchFromLocalDatastore (unsaved)`, async () => {
+      try {
+        const object = new TestObject();
+        await object.fetchFromLocalDatastore();
+      } catch (e) {
+        assert.equal(e.message, 'Cannot fetch an unsaved ParseObject');
+      }
+    });
+
+    it(`${controller.name} cannot fetchFromLocalDatastore (pinned but not saved)`, async () => {
+      try {
+        const object = new TestObject();
+        await object.pin();
+        await object.fetchFromLocalDatastore();
+      } catch (e) {
+        assert.equal(e.message, 'Cannot fetch an unsaved ParseObject');
+      }
+    });
+
+    it(`${controller.name} can fetchFromLocalDatastore (saved)`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+
+      obj1.set('field', 'test');
+      obj1.set('foo', 'bar');
+      await obj1.pin();
+      await obj1.save();
+
+      obj2.id = obj1.id;
+      await obj2.fetchFromLocalDatastore();
+      assert.deepEqual(obj1.toJSON(), obj2.toJSON());
+      assert.deepEqual(obj1._toFullJSON(), obj2._toFullJSON());
+
+      const obj3 = TestObject.createWithoutData(obj1.id);
+      await obj3.fetchFromLocalDatastore();
+      assert.deepEqual(obj1.toJSON(), obj3.toJSON());
+      assert.deepEqual(obj1._toFullJSON(), obj3._toFullJSON());
+
+      const obj4 = TestObject.createWithoutData(obj1.id);
+      obj4.set('field', 'will not override');
+      await obj4.fetchFromLocalDatastore();
+      assert.equal(obj4.get('field'), 'will not override');
+      assert.equal(obj4.get('foo'), 'bar');
+    });
+
+    it(`${controller.name} can fetchFromLocalDatastore with children`, async () => {
+      const obj1 = new TestObject();
+      const obj2 = new TestObject();
+      const child = new TestObject();
+      const grandchild = new TestObject();
+
+      obj1.set('field', 'test');
+      obj1.set('child', child);
+      await obj1.save();
+      await obj1.pin();
+
+      grandchild.set('field', 'shouldAlsoHave');
+      child.set('field', 'shouldHave');
+      child.set('grandchild', grandchild);
+      await child.save();
+
+      obj2.id = obj1.id;
+      await obj2.fetchFromLocalDatastore();
+
+      assert.deepEqual(obj1.toJSON(), obj2.toJSON());
+      assert.deepEqual(obj1._toFullJSON(), obj2._toFullJSON());
+      assert.deepEqual(obj2.toJSON().child.field, 'shouldHave');
+      assert.deepEqual(obj2.toJSON().child.grandchild.field, 'shouldAlsoHave');
+    });
+
+    it(`${controller.name} can fetchFromLocalDatastore break multiple cycle`, async () => {
+      const A = new TestObject({ value: 'A'});
+      const B = new TestObject({ value: 'B'});
+      const C = new TestObject({ value: 'C'});
+      const D = new TestObject({ value: 'D'});
+      const E = new TestObject({ value: 'E'});
+
+      await Parse.Object.saveAll([A, B, C, D, E]);
+      /*
+        Cycles:
+          A->B->A
+          A->C->D->C
+
+                A ------|
+              / | \     |
+             B  C  E    D
+            /   |   \
+           A    D    C
+                |
+                C
+      */
+      A.set('B', B);
+      A.set('C', C);
+      A.set('E', E);
+      A.set('D', D);
+      B.set('A', A);
+      C.set('D', D);
+      D.set('C', C);
+      E.set('C', C);
+      await Parse.Object.saveAll([A, B, C, D, E]);
+      await A.pin();
+
+      const object = new TestObject();
+
+      object.id = A.id;
+      await object.fetchFromLocalDatastore();
+      const root = object.toJSON();
+
+      assert.deepEqual(root.B.A.__type, 'Pointer');
+      assert.deepEqual(root.C.D.C.__type, 'Pointer');
+      assert.deepEqual(root.E.C.__type, 'Object');
+      assert.deepEqual(root.D.__type, 'Object');
+    });
+
+    it('fetch updates LocalDatastore', async () => {
+      const item = new Item({ foo: 'bar' });
+      await item.save();
+      await item.pin();
+
+      const params = { id: item.id };
+      await Parse.Cloud.run('TestFetchFromLocalDatastore', params);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+
+      assert.equal(localDatastore[`Item_${item.id}`].foo, 'bar');
+
+      const itemAgain = new Item();
+      itemAgain.id = item.id;
+      const fetchedItem = await itemAgain.fetch();
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+
+      assert.equal(itemAgain.get('foo'), 'changed');
+      assert.equal(fetchedItem.get('foo'), 'changed');
+      assert.equal(localDatastore[`Item_${item.id}`].foo, 'changed');
+    });
+
+    it('fetchAll updates LocalDatastore', async () => {
+      const item1 = new Item({ foo: 'bar' });
+      const item2 = new Item({ foo: 'baz' });
+
+      await Parse.Object.saveAll([item1, item2]);
+      await Parse.Object.pinAll([item1, item2]);
+
+      let params = { id: item1.id };
+      await Parse.Cloud.run('TestFetchFromLocalDatastore', params);
+      params = { id: item2.id };
+      await Parse.Cloud.run('TestFetchFromLocalDatastore', params);
+
+      let localDatastore = await Parse.LocalDatastore._getAllContents();
+
+      assert.equal(localDatastore[`Item_${item1.id}`].foo, 'bar');
+      assert.equal(localDatastore[`Item_${item2.id}`].foo, 'baz');
+
+      const item1Again = new Item();
+      item1Again.id = item1.id;
+      const item2Again = new Item();
+      item2Again.id = item2.id;
+
+      const fetchedItems = await Parse.Object.fetchAll([item1Again, item2Again]);
+
+      localDatastore = await Parse.LocalDatastore._getAllContents();
+
+      assert.equal(item1Again.get('foo'), 'changed');
+      assert.equal(item2Again.get('foo'), 'changed');
+      assert.equal(fetchedItems[0].get('foo'), 'changed');
+      assert.equal(fetchedItems[1].get('foo'), 'changed');
+      assert.equal(localDatastore[`Item_${fetchedItems[0].id}`].foo, 'changed');
+      assert.equal(localDatastore[`Item_${fetchedItems[1].id}`].foo, 'changed');
+    });
+  });
+
+  describe(`Parse Query Pinning (${controller.name})`, () => {
+    beforeEach(async () => {
+      const StorageController = require(controller.file);
+      Parse.CoreManager.setAsyncStorage(mockRNStorage);
+      Parse.CoreManager.setLocalDatastoreController(StorageController);
+      Parse.enableLocalDatastore();
+      Parse.LocalDatastore._clear();
+
+      const numbers = [];
+      for (let i = 0; i < 10; i++) {
+        numbers[i] = new Parse.Object({ className: 'BoxedNumber', number: i });
+      }
+      await Parse.Object.saveAll(numbers);
+      await Parse.Object.pinAll(numbers);
+    });
+
+    it(`${controller.name} can query from pin with name`, async () => {
+      const obj1 = new TestObject({ field: 1 });
+      const obj2 = new TestObject({ field: 2 });
+      const obj3 = new TestObject({ field: 3 });
+      const item = new Item();
+      const objects = [obj1, obj2, obj3, item];
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.pinAllWithName('test_pin', objects);
+      const query = new Parse.Query(TestObject);
+      query.greaterThan('field', 1);
+      query.fromPinWithName('test_pin');
+      const results = await query.find();
+
+      assert.equal(results.length, 2);
+      assert(results[0].get('field') > 1);
+      assert(results[1].get('field') > 1);
+    });
+
+    it(`${controller.name} can query from local datastore`, async () => {
+      const obj1 = new TestObject({ field: 1 });
+      const obj2 = new TestObject({ field: 2 });
+      const obj3 = new TestObject({ field: 3 });
+      const objects = [obj1, obj2, obj3];
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.pinAll(objects);
+      const query = new Parse.Query(TestObject);
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 3);
+    });
+
+    it(`${controller.name} can query from pin`, async () => {
+      const obj1 = new TestObject({ field: 1 });
+      const obj2 = new TestObject({ field: 2 });
+      const obj3 = new TestObject({ field: 3 });
+      const objects = [obj1, obj2, obj3];
+      await Parse.Object.saveAll(objects);
+
+      await Parse.Object.pinAll(objects);
+      const query = new Parse.Query(TestObject);
+      query.fromPin();
+      const results = await query.find();
+
+      assert.equal(results.length, 3);
+    });
+
+    it(`${controller.name} can do basic queries`, async () => {
+      const baz = new TestObject({ foo: 'baz' });
+      const qux = new TestObject({ foo: 'qux' });
+      await Parse.Object.saveAll([baz, qux]);
+      await Parse.Object.pinAll([baz, qux]);
+      const query = new Parse.Query(TestObject);
+      query.equalTo('foo', 'baz');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('foo'), 'baz');
+    });
+
+    it(`${controller.name} can do a query with a limit`, async () => {
+      const baz = new TestObject({ foo: 'baz' });
+      const qux = new TestObject({ foo: 'qux' });
+      await Parse.Object.saveAll([baz, qux]);
+      await Parse.Object.pinAll([baz, qux]);
+      const query = new Parse.Query(TestObject);
+      query.limit(1);
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      assert.equal(['baz', 'qux'].includes(results[0].get('foo')), true);
+    });
+
+    it(`${controller.name} can do equalTo queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.equalTo('number', 3);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 1);
+    });
+
+    it('can do containedBy queries with numbers', async () => {
+      const objects = [
+        new TestObject({ containedBy:true, numbers: [0, 1, 2] }),
+        new TestObject({ containedBy:true, numbers: [2, 0] }),
+        new TestObject({ containedBy:true, numbers: [1, 2, 3, 4] }),
+      ];
+
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('containedBy', true);
+      query.containedBy('numbers', [1, 2, 3, 4, 5]);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 1);
+    });
+
+    it('can do containedBy queries with pointer', async () => {
+      const objects = Array.from(Array(10).keys()).map((idx) => {
+        const obj = new Parse.Object('Object');
+        obj.set('key', idx);
+        return obj;
+      });
+
+      const parent1 = new Parse.Object('Parent');
+      const parent2 = new Parse.Object('Parent');
+      const parent3 = new Parse.Object('Parent');
+
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      // [0, 1, 2]
+      parent1.set('objects', objects.slice(0, 3));
+
+      const shift = objects.shift();
+      // [2, 0]
+      parent2.set('objects', [objects[1], shift]);
+
+      // [1, 2, 3, 4]
+      parent3.set('objects', objects.slice(1, 4));
+
+      await Parse.Object.saveAll([parent1, parent2, parent3]);
+      await Parse.Object.pinAll([parent1, parent2, parent3]);
+
+      const query = new Parse.Query('Parent');
+      query.containedBy('objects', objects);
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      assert.equal(results[0].id, parent3.id);
+    });
+
+    it(`${controller.name} can test equality with undefined`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.equalTo('number', undefined);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 0);
+    });
+
+    it(`${controller.name} can perform lessThan queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.lessThan('number', 7);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 7);
+    });
+
+    it(`${controller.name} can perform lessThanOrEqualTo queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.lessThanOrEqualTo('number', 7);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 8);
+    });
+
+    it(`${controller.name} can perform greaterThan queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.greaterThan('number', 7);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 2);
+    });
+
+    it(`${controller.name} can perform greaterThanOrEqualTo queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.greaterThanOrEqualTo('number', 7);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 3);
+    });
+
+    it(`${controller.name} can combine lessThanOrEqualTo and greaterThanOrEqualTo queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.lessThanOrEqualTo('number', 7);
+      query.greaterThanOrEqualTo('number', 7);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 1);
+    });
+
+    it(`${controller.name} can combine lessThan and greaterThan queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.lessThan('number', 9);
+      query.greaterThan('number', 3);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 5);
+    });
+
+    it(`${controller.name} can perform notEqualTo queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.notEqualTo('number', 5);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 9);
+    });
+
+    it(`${controller.name} can perform containedIn queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.containedIn('number', [3,5,7,9,11]);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 4);
+    });
+
+    it(`${controller.name} can perform notContainedIn queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.notContainedIn('number', [3,5,7,9,11]);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 6);
+    });
+
+    it(`${controller.name} can test objectId in containedIn queries`, async () => {
+      const numbers = await new Parse.Query('BoxedNumber').ascending('number').find();
+      const ids = [numbers[2].id, numbers[3].id, 'nonsense'];
+      const query = new Parse.Query('BoxedNumber');
+      query.containedIn('objectId', ids);
+      query.ascending('number');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 2);
+      assert.equal(results[0].get('number'), 2);
+      assert.equal(results[1].get('number'), 3);
+    });
+
+    it(`${controller.name} can test objectId in equalTo queries`, async () => {
+      const numbers = await new Parse.Query('BoxedNumber').ascending('number').find();
+      const id = numbers[5].id;
+      const query = new Parse.Query('BoxedNumber');
+      query.equalTo('objectId', id);
+      query.ascending('number');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('number'), 5);
+    });
+
+    it(`${controller.name} can find no elements`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.equalTo('number', 15);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 0);
+    });
+
+    it(`${controller.name} handles when find throws errors`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.equalTo('$foo', 'bar');
+      query.fromLocalDatastore();
+      try {
+        await query.find();
+        assert.equal(true, false);
+      } catch (e) {
+        assert.equal(e.code, Parse.Error.INVALID_KEY_NAME);
+      }
+    });
+
+    it(`${controller.name} can get by objectId`, async () => {
+      const object = new TestObject();
+      await object.pin();
+      await object.save();
+      const query = new Parse.Query(TestObject);
+      query.fromLocalDatastore();
+
+      const result = await query.get(object.id);
+      assert.equal(result.id, object.id);
+      assert(result.createdAt);
+      assert(result.updatedAt);
+    });
+
+    it(`${controller.name} handles get with undefined id`, async () => {
+      const object = new TestObject();
+      await object.pin();
+      await object.save();
+      const query = new Parse.Query(TestObject);
+      query.fromLocalDatastore();
+      try {
+        await query.get(undefined);
+        assert.equal(false, true);
+      } catch(e) {
+        assert.equal(e.code, Parse.Error.OBJECT_NOT_FOUND);
+      }
+    });
+
+    it(`${controller.name} can query for the first result`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.descending('number');
+      query.fromLocalDatastore();
+      const result = await query.first();
+      assert.equal(result.get('number'), 9);
+    });
+
+    it(`${controller.name} can query for the first with no results`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.equalTo('number', 20);
+      query.fromLocalDatastore();
+      const result = await query.first();
+      assert.equal(result, undefined);
+    });
+
+    it(`${controller.name} can query for the first with two results`, async () => {
+      const objects = [new TestObject({x: 44}), new TestObject({x: 44})];
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+      const query = new Parse.Query(TestObject);
+      query.equalTo('x', 44);
+      query.fromLocalDatastore();
+
+      const result = await query.first();
+      assert.equal(result.get('x'), 44);
+    });
+
+    it(`${controller.name} handles when first throws errors`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.equalTo('$foo', 'bar');
+      try {
+        query.fromLocalDatastore();
+        await query.first();
+        assert.equal(true, false);
+      } catch (e) {
+        assert.equal(e.code, Parse.Error.INVALID_KEY_NAME);
+      }
+    });
+
+    it(`${controller.name} can test object inequality`, async () => {
+      const item1 = new TestObject();
+      const item2 = new TestObject();
+      const container1 = new Parse.Object({className: 'CoolContainer', item: item1});
+      const container2 = new Parse.Object({className: 'CoolContainer', item: item2});
+      const objects = [item1, item2, container1, container2];
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+      const query = new Parse.Query('CoolContainer');
+      query.notEqualTo('item', item1);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 1);
+    });
+
+    it(`${controller.name} can skip`, async () => {
+      const objects = [
+        new TestObject({ canSkip: true }),
+        new TestObject({ canSkip: true }),
+        new TestObject({ canSkip: true }),
+      ];
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      let query = new Parse.Query(TestObject);
+      query.equalTo('canSkip', true);
+      query.skip(1);
+      query.fromLocalDatastore();
+      let results = await query.find();
+      assert.equal(results.length, 2);
+
+      query = new Parse.Query(TestObject);
+      query.equalTo('canSkip', true);
+      query.skip(3);
+      query.fromLocalDatastore();
+      results = await query.find();
+      assert.equal(results.length, 0);
+    });
+
+    it(`${controller.name} does not consider skip in count queries`, async () => {
+      await Parse.Object.saveAll([
+        new TestObject({ skipCount: true }),
+        new TestObject({ skipCount: true }),
+        new TestObject({ skipCount: true })
+      ]);
+      let query = new Parse.Query(TestObject);
+      query.equalTo('skipCount', true);
+      query.fromLocalDatastore();
+      let count = await query.count();
+      assert.equal(count, 3);
+
+      query = new Parse.Query(TestObject);
+      query.equalTo('skipCount', true);
+      query.skip(1);
+      query.fromLocalDatastore();
+      count = await query.count();
+      assert.equal(count, 3);
+
+      query = new Parse.Query(TestObject);
+      query.equalTo('skipCount', true);
+      query.skip(2);
+      query.fromLocalDatastore();
+      count = await query.count();
+      assert.equal(count, 3);
+    });
+
+    it(`${controller.name} can perform count queries`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.greaterThan('number', 1);
+      query.fromLocalDatastore();
+      const count = await query.count()
+      assert.equal(count, 8);
+    });
+
+    it(`${controller.name} can order by ascending numbers`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.ascending('number');
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results[0].get('number'), 0);
+      assert.equal(results[9].get('number'), 9);
+    });
+
+    it(`${controller.name} can order by descending numbers`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.descending('number');
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results[0].get('number'), 9);
+      assert.equal(results[9].get('number'), 0);
+    });
+
+    it(`${controller.name} can order by ascending number then descending string`, async () => {
+      const objects = [
+        new TestObject({ doubleOrder: true, number: 3, string: 'a' }),
+        new TestObject({ doubleOrder: true, number: 1, string: 'b' }),
+        new TestObject({ doubleOrder: true, number: 3, string: 'c' }),
+        new TestObject({ doubleOrder: true, number: 2, string: 'd' }),
+      ];
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('doubleOrder', true);
+      query.ascending('number').addDescending('string');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 4);
+      assert.equal(results[0].get('number'), 1);
+      assert.equal(results[0].get('string'), 'b');
+      assert.equal(results[1].get('number'), 2);
+      assert.equal(results[1].get('string'), 'd');
+      assert.equal(results[2].get('number'), 3);
+      assert.equal(results[2].get('string'), 'c');
+      assert.equal(results[3].get('number'), 3);
+      assert.equal(results[3].get('string'), 'a');
+    });
+
+    it(`${controller.name} can order by descending number then ascending string`, async () => {
+      const objects = [
+        new TestObject({ otherDoubleOrder: true, number: 3, string: 'a' }),
+        new TestObject({ otherDoubleOrder: true, number: 1, string: 'b' }),
+        new TestObject({ otherDoubleOrder: true, number: 3, string: 'c' }),
+        new TestObject({ otherDoubleOrder: true, number: 2, string: 'd' }),
+      ];
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('otherDoubleOrder', true);
+      query.descending('number').addAscending('string');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 4);
+      assert.equal(results[0].get('number'), 3);
+      assert.equal(results[0].get('string'), 'a');
+      assert.equal(results[1].get('number'), 3);
+      assert.equal(results[1].get('string'), 'c');
+      assert.equal(results[2].get('number'), 2);
+      assert.equal(results[2].get('string'), 'd');
+      assert.equal(results[3].get('number'), 1);
+      assert.equal(results[3].get('string'), 'b');
+    });
+
+    it(`${controller.name} can order by descending number and string`, async () => {
+      const objects = [
+        new TestObject({ doubleDescending: true, number: 3, string: 'a' }),
+        new TestObject({ doubleDescending: true, number: 1, string: 'b' }),
+        new TestObject({ doubleDescending: true, number: 3, string: 'c' }),
+        new TestObject({ doubleDescending: true, number: 2, string: 'd' }),
+      ];
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      let query = new Parse.Query(TestObject);
+      query.equalTo('doubleDescending', true);
+      query.descending('number,string');
+      query.fromLocalDatastore();
+      let results = await query.find();
+
+      assert.equal(results.length, 4);
+      assert.equal(results[0].get('number'), 3);
+      assert.equal(results[0].get('string'), 'c');
+      assert.equal(results[1].get('number'), 3);
+      assert.equal(results[1].get('string'), 'a');
+      assert.equal(results[2].get('number'), 2);
+      assert.equal(results[2].get('string'), 'd');
+      assert.equal(results[3].get('number'), 1);
+      assert.equal(results[3].get('string'), 'b');
+
+      query = new Parse.Query(TestObject);
+      query.equalTo('doubleDescending', true);
+      query.descending('number, string');
+      query.fromLocalDatastore();
+      results = await query.find();
+
+      assert.equal(results.length, 4);
+      assert.equal(results[0].get('number'), 3);
+      assert.equal(results[0].get('string'), 'c');
+      assert.equal(results[1].get('number'), 3);
+      assert.equal(results[1].get('string'), 'a');
+      assert.equal(results[2].get('number'), 2);
+      assert.equal(results[2].get('string'), 'd');
+      assert.equal(results[3].get('number'), 1);
+      assert.equal(results[3].get('string'), 'b');
+
+      query = new Parse.Query(TestObject);
+      query.equalTo('doubleDescending', true);
+      query.descending(['number', 'string']);
+      query.fromLocalDatastore();
+      results = await query.find();
+
+      assert.equal(results.length, 4);
+      assert.equal(results[0].get('number'), 3);
+      assert.equal(results[0].get('string'), 'c');
+      assert.equal(results[1].get('number'), 3);
+      assert.equal(results[1].get('string'), 'a');
+      assert.equal(results[2].get('number'), 2);
+      assert.equal(results[2].get('string'), 'd');
+      assert.equal(results[3].get('number'), 1);
+      assert.equal(results[3].get('string'), 'b');
+
+      query = new Parse.Query(TestObject);
+      query.equalTo('doubleDescending', true);
+      query.descending('number', 'string');
+      query.fromLocalDatastore();
+      results = await query.find();
+
+      assert.equal(results.length, 4);
+      assert.equal(results[0].get('number'), 3);
+      assert.equal(results[0].get('string'), 'c');
+      assert.equal(results[1].get('number'), 3);
+      assert.equal(results[1].get('string'), 'a');
+      assert.equal(results[2].get('number'), 2);
+      assert.equal(results[2].get('string'), 'd');
+      assert.equal(results[3].get('number'), 1);
+      assert.equal(results[3].get('string'), 'b');
+    });
+
+
+    it(`${controller.name} can not order by password`, async () => {
+      const query = new Parse.Query('BoxedNumber');
+      query.ascending('_password');
+      query.fromLocalDatastore();
+      try {
+        await query.find();
+        assert.equal(true, false);
+      } catch (e) {
+        assert.equal(e.code, Parse.Error.INVALID_KEY_NAME);
+      }
+    });
+
+    it(`${controller.name} can order by _created_at`, async () => {
+      const obj1 = await new Parse.Object({className: 'TestObject', orderedDate: true}).save();
+      const obj2 = await new Parse.Object({className: 'TestObject', orderedDate: true}).save();
+      const obj3 = await new Parse.Object({className: 'TestObject', orderedDate: true}).save();
+      const obj4 = await new Parse.Object({className: 'TestObject', orderedDate: true}).save();
+
+      await Parse.Object.pinAll([obj1, obj2, obj3, obj4]);
+
+      const query = new Parse.Query('TestObject');
+      query.equalTo('orderedDate', true);
+      query.ascending('_created_at');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert(results[0].createdAt < results[1].createdAt);
+      assert(results[1].createdAt < results[2].createdAt);
+      assert(results[2].createdAt < results[3].createdAt);
+    });
+
+    it(`${controller.name} can order by createdAt`, async () => {
+      const obj1 = await new Parse.Object({className: 'TestObject', orderedDate2: true}).save();
+      const obj2 = await new Parse.Object({className: 'TestObject', orderedDate2: true}).save();
+      const obj3 = await new Parse.Object({className: 'TestObject', orderedDate2: true}).save();
+      const obj4 = await new Parse.Object({className: 'TestObject', orderedDate2: true}).save();
+
+      await Parse.Object.pinAll([obj1, obj2, obj3, obj4]);
+
+      const query = new Parse.Query('TestObject');
+      query.equalTo('orderedDate2', true);
+      query.descending('createdAt');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert(results[0].createdAt > results[1].createdAt);
+      assert(results[1].createdAt > results[2].createdAt);
+      assert(results[2].createdAt > results[3].createdAt);
+    });
+
+    it(`${controller.name} can order by _updated_at`, async () => {
+      const obj1 = await new Parse.Object({className: 'TestObject', orderedDate3: true}).save();
+      const obj2 = await new Parse.Object({className: 'TestObject', orderedDate3: true}).save();
+      const obj3 = await new Parse.Object({className: 'TestObject', orderedDate3: true}).save();
+      const obj4 = await new Parse.Object({className: 'TestObject', orderedDate3: true}).save();
+
+      await Parse.Object.pinAll([obj1, obj2, obj3, obj4]);
+
+      const query = new Parse.Query('TestObject');
+      query.equalTo('orderedDate3', true);
+      query.ascending('_updated_at');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert(results[0].updatedAt < results[1].updatedAt);
+      assert(results[1].updatedAt < results[2].updatedAt);
+      assert(results[2].updatedAt < results[3].updatedAt);
+    });
+
+    it(`${controller.name} can order by updatedAt`, async () => {
+      const obj1 = await new Parse.Object({className: 'TestObject', orderedDate4: true}).save();
+      const obj2 = await new Parse.Object({className: 'TestObject', orderedDate4: true}).save();
+      const obj3 = await new Parse.Object({className: 'TestObject', orderedDate4: true}).save();
+      const obj4 = await new Parse.Object({className: 'TestObject', orderedDate4: true}).save();
+
+      await Parse.Object.pinAll([obj1, obj2, obj3, obj4]);
+
+      const query = new Parse.Query('TestObject');
+      query.equalTo('orderedDate4', true);
+      query.ascending('updatedAt');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert(results[0].updatedAt < results[1].updatedAt);
+      assert(results[1].updatedAt < results[2].updatedAt);
+      assert(results[2].updatedAt < results[3].updatedAt);
+    });
+
+    it(`${controller.name} can test time equality`, async () => {
+      const obj1 = await new Parse.Object({className: 'TestObject', timed: true, name: 'item2'}).save();
+      const obj2 = await new Parse.Object({className: 'TestObject', timed: true, name: 'item1'}).save();
+      const obj3 = await new Parse.Object({className: 'TestObject', timed: true, name: 'item3'}).save();
+      const last = await new Parse.Object({className: 'TestObject', timed: true, name: 'item4'}).save();
+
+      await Parse.Object.pinAll([obj1, obj2, obj3, last]);
+
+      const query = new Parse.Query('TestObject');
+      query.equalTo('timed', true);
+      query.equalTo('createdAt', last.createdAt);
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('name'), 'item4');
+    });
+
+    it(`${controller.name} can test time inequality`, async () => {
+      const obj1 = await new Parse.Object({className: 'TestObject', timed2: true, name: 'item1'}).save();
+      const obj2 = await new Parse.Object({className: 'TestObject', timed2: true, name: 'item2'}).save();
+      const obj3 = await new Parse.Object({className: 'TestObject', timed2: true, name: 'item3'}).save();
+      const obj4 = await new Parse.Object({className: 'TestObject', timed2: true, name: 'item4'}).save();
+
+      await Parse.Object.pinAll([obj1, obj2, obj3, obj4]);
+
+      let query = new Parse.Query('TestObject');
+      query.equalTo('timed2', true);
+      query.lessThan('createdAt', obj3.createdAt);
+      query.ascending('createdAt');
+      query.fromLocalDatastore();
+      let results = await query.find();
+
+      assert.equal(results.length, 2);
+      assert.equal(results[0].id, obj1.id);
+      assert.equal(results[1].id, obj2.id);
+
+      query = new Parse.Query('TestObject');
+      query.equalTo('timed2', true);
+      query.greaterThan('createdAt', obj3.createdAt);
+      query.fromLocalDatastore();
+      results = await query.find();
+      assert.equal(results.length, 1);
+      assert.equal(results[0].id, obj4.id);
+    });
+
+    it(`${controller.name} can test string matching`, async () => {
+      const obj1 = new TestObject();
+      obj1.set('myString', 'football');
+      const obj2 = new TestObject();
+      obj2.set('myString', 'soccer');
+      await Parse.Object.saveAll([obj1, obj2]);
+      await Parse.Object.pinAll([obj1, obj2]);
+      let query = new Parse.Query(TestObject);
+      query.matches('myString', '^fo*\\wb[^o]l+$');
+      query.fromLocalDatastore();
+      let results = await query.find();
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('myString'), 'football');
+
+      query = new Parse.Query(TestObject);
+      query.matches('myString', /^fo*\wb[^o]l+$/);
+      query.fromLocalDatastore();
+      results = await query.find();
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('myString'), 'football');
+    });
+
+    it(`${controller.name} can test case insensitive regex`, async () => {
+      const obj = new TestObject();
+      obj.set('myString', 'hockey');
+      await obj.save();
+      await obj.pin();
+      const query = new Parse.Query(TestObject);
+      query.matches('myString', 'Hockey', 'i');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('myString'), 'hockey');
+    });
+
+    it(`${controller.name} fails for invalid regex options`, async () => {
+      const query = new Parse.Query(TestObject);
+      query.matches('myString', 'football', 'some invalid thing');
+      query.fromLocalDatastore();
+      try {
+        await query.find();
+        assert.equal(true, false);
+      } catch (e) {
+        assert.equal(e.code, Parse.Error.INVALID_QUERY);
+      }
+    });
+
+    it(`${controller.name} can use a regex with all modifiers`, async () => {
+      const obj = new TestObject();
+      obj.set('website', '\n\nbuffer\n\nparse.COM');
+      await obj.save();
+      await obj.pin();
+
+      const query = new Parse.Query(TestObject);
+      query.matches('website',/parse\.com/,'mixs');
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 1);
+    });
+
+    it(`${controller.name} can include regexp modifiers in the constructor`, async () => {
+      const obj = new TestObject();
+      obj.set('website', '\n\nbuffer\n\nparse.COM');
+      await obj.save();
+      await obj.pin();
+
+      const query = new Parse.Query(TestObject);
+      query.matches('website', /parse\.com/mi);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 1);
+    });
+
+    it(`${controller.name} can test contains`, async () => {
+      const someAscii = "\\E' !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTU" +
+                      "VWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~'";
+      const objects = [
+        new TestObject({contains: true, myString: 'zax' + someAscii + 'qub'}),
+        new TestObject({contains: true, myString: 'start' + someAscii}),
+        new TestObject({contains: true, myString: someAscii + 'end'}),
+        new TestObject({contains: true, myString: someAscii})
+      ];
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      let query = new Parse.Query(TestObject);
+      query.equalTo('contains', true);
+      query.startsWith('myString', someAscii);
+      query.fromLocalDatastore();
+      let results = await query.find();
+
+      assert.equal(results.length, 2);
+      query = new Parse.Query(TestObject);
+      query.equalTo('contains', true);
+      query.startsWith('myString', someAscii);
+      query.fromLocalDatastore();
+      results = await query.find();
+      assert.equal(results.length, 2);
+    });
+
+    it(`${controller.name} can test if a key exists`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        const item = new TestObject();
+        if (i % 2) {
+          item.set('y', i + 1);
+        } else {
+          item.set('z', i + 1);
+        }
+        objects.push(item);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const query = new Parse.Query(TestObject);
+      query.exists('y');
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 5);
+      for (let i = 0; i < results.length; i++) {
+        assert(results[i].has('y'));
+      }
+    });
+
+    it(`${controller.name} can test if a key does not exist`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        const item = new TestObject({ dne: true });
+        if (i % 2) {
+          item.set('y', i + 1);
+        } else {
+          item.set('z', i + 1);
+        }
+        objects.push(item);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('dne', true);
+      query.doesNotExist('y');
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 5);
+      for (let i = 0; i < results.length; i++) {
+        assert(results[i].has('z'));
+      }
+    });
+
+    it(`${controller.name} can test if a relation exists`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        const container = new Parse.Object('Container', { relation_exists: true });
+        if (i % 2) {
+          container.set('y', i);
+        } else {
+          const item = new TestObject();
+          item.set('x', i);
+          container.set('x', item);
+          objects.push(item);
+        }
+        objects.push(container);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const query = new Parse.Query('Container');
+      query.equalTo('relation_exists', true);
+      query.exists('x');
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 5);
+      for (let i = 0; i < results.length; i++) {
+        assert(results[i].has('x'));
+      }
+    });
+
+    it(`${controller.name} can test if a relation does not exist`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        const container = new Parse.Object('Container', { relation_dne: true });
+        if (i % 2) {
+          container.set('y', i);
+        } else {
+          const item = new TestObject();
+          item.set('x', i);
+          container.set('x', item);
+          objects.push(item);
+        }
+        objects.push(container);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const query = new Parse.Query('Container');
+      query.equalTo('relation_dne', true);
+      query.doesNotExist('x');
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 5);
+      for (let i = 0; i < results.length; i++) {
+        assert(results[i].has('y'));
+      }
+    });
+
+    // it(`${controller.name} does not include by default`, async () => {
+    //   const child = new TestObject();
+    //   const parent = new Parse.Object('Container');
+    //   child.set('foo', 'bar');
+    //   parent.set('child', child);
+    //   await Parse.Object.saveAll([child, parent]);
+    //   await Parse.Object.pinAll([child, parent]);
+
+    //   const query = new Parse.Query('Container');
+    //   query.equalTo('objectId', parent.id);
+    //   query.fromLocalDatastore();
+    //   const results = await query.find();
+    //   assert.equal(results.length, 1);
+    //   const parentAgain = results[0];
+
+    //   assert(parentAgain.get('child'));
+    //   assert(parentAgain.get('child').id);
+    //   assert(!parentAgain.get('child').get('foo'));
+    // });
+
+    it(`${controller.name} can include nested objects`, async () => {
+      const child = new TestObject();
+      const parent = new Parse.Object('Container');
+      child.set('foo', 'bar');
+      parent.set('child', child);
+      await Parse.Object.saveAll([child, parent]);
+      await Parse.Object.pinAll([child, parent]);
+
+      const query = new Parse.Query('Container');
+      query.equalTo('objectId', parent.id);
+      query.include('child');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      const parentAgain = results[0];
+
+      assert(parentAgain.get('child'));
+      assert(parentAgain.get('child').id);
+      assert.equal(parentAgain.get('child').get('foo'), 'bar');
+    });
+
+    it(`${controller.name} can includeAll nested objects`, async () => {
+      const child1 = new TestObject({ foo: 'bar' });
+      const child2 = new TestObject({ foo: 'baz' });
+      const child3 = new TestObject({ foo: 'bin' });
+      const parent = new Parse.Object('Container');
+      parent.set('child1', child1);
+      parent.set('child2', child2);
+      parent.set('child3', child3);
+      await Parse.Object.saveAll([child1, child2, child3, parent]);
+      await Parse.Object.pinAll([child1, child2, child3, parent]);
+
+      const query = new Parse.Query('Container');
+      query.equalTo('objectId', parent.id);
+      query.includeAll();
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      const parentAgain = results[0];
+      assert.equal(parentAgain.get('child1').get('foo'), 'bar');
+      assert.equal(parentAgain.get('child2').get('foo'), 'baz');
+      assert.equal(parentAgain.get('child3').get('foo'), 'bin');
+    });
+
+    it(`${controller.name} can includeAll nested objects in .each`, async () => {
+      const child1 = new TestObject({ foo: 'bar' });
+      const child2 = new TestObject({ foo: 'baz' });
+      const child3 = new TestObject({ foo: 'bin' });
+      const parent = new Parse.Object('Container');
+      parent.set('child1', child1);
+      parent.set('child2', child2);
+      parent.set('child3', child3);
+      await Parse.Object.saveAll([child1, child2, child3, parent]);
+
+      const query = new Parse.Query('Container');
+      query.equalTo('objectId', parent.id);
+      query.includeAll();
+      query.fromLocalDatastore();
+      await query.each((obj) => {
+        assert.equal(obj.get('child1').get('foo'), 'bar');
+        assert.equal(obj.get('child2').get('foo'), 'baz');
+        assert.equal(obj.get('child3').get('foo'), 'bin');
+      });
+    });
+
+    it(`${controller.name} can include nested objects via array`, async () => {
+      const child = new TestObject();
+      const parent = new Parse.Object('Container');
+      child.set('foo', 'bar');
+      parent.set('child', child);
+      await Parse.Object.saveAll([child, parent]);
+      await Parse.Object.pinAll([child, parent]);
+
+      const query = new Parse.Query('Container');
+      query.equalTo('objectId', parent.id);
+      query.include(['child']);
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      const parentAgain = results[0];
+      assert(parentAgain.get('child'));
+      assert(parentAgain.get('child').id);
+      assert.equal(parentAgain.get('child').get('foo'), 'bar');
+    });
+
+    it(`${controller.name} can do a nested include`, async () => {
+      const Child = Parse.Object.extend('Child');
+      const Parent = Parse.Object.extend('Parent');
+      const Grandparent = Parse.Object.extend('Grandparent');
+
+      const objects = [];
+      for (let i = 0; i < 5; i++) {
+        const grandparent = new Grandparent({
+          nested: true,
+          z: i,
+          parent: new Parent({
+            y: i,
+            child: new Child({
+              x: i
+            }),
+          }),
+        });
+
+        objects.push(grandparent);
+      }
+
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const q = new Parse.Query('Grandparent');
+      q.equalTo('nested', true);
+      q.include('parent.child');
+      q.fromLocalDatastore();
+      const results = await q.find();
+
+      assert.equal(results.length, 5);
+      results.forEach((o) => {
+        assert.equal(o.get('z'), o.get('parent').get('y'));
+        assert.equal(o.get('z'), o.get('parent').get('child').get('x'));
+      });
+    });
+
+    it(`${controller.name} can include without changing dirty`, async () => {
+      const parent = new Parse.Object('ParentObject');
+      const child = new Parse.Object('ChildObject');
+      parent.set('child', child);
+      child.set('foo', 'bar');
+
+      await Parse.Object.saveAll([child, parent]);
+      await Parse.Object.pinAll([child, parent]);
+
+      const query = new Parse.Query('ParentObject');
+      query.include('child');
+      query.equalTo('objectId', parent.id);
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      const parentAgain = results[0];
+      const childAgain = parentAgain.get('child');
+      assert.equal(child.id, childAgain.id);
+      assert.equal(parent.id, parentAgain.id);
+      assert.equal(childAgain.get('foo'), 'bar');
+      assert(!parentAgain.dirty());
+      assert(!childAgain.dirty());
+    });
+
+    it(`${controller.name} uses subclasses when creating objects`, async () => {
+      const ParentObject = Parse.Object.extend({ className: 'ParentObject' });
+      let ChildObject = Parse.Object.extend('ChildObject', {
+        foo() {
+          return 'foo';
+        }
+      });
+
+      const parent = new ParentObject();
+      const child = new ChildObject();
+      parent.set('child', child);
+      await Parse.Object.saveAll([child, parent]);
+      await Parse.Object.pinAll([child, parent]);
+      ChildObject = Parse.Object.extend('ChildObject', {
+        bar() {
+          return 'bar';
+        }
+      });
+
+      const query = new Parse.Query(ParentObject);
+      query.equalTo('objectId', parent.id);
+      query.include('child');
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      const parentAgain = results[0];
+      const childAgain = parentAgain.get('child');
+      assert.equal(childAgain.foo(), 'foo');
+      assert.equal(childAgain.bar(), 'bar');
+    });
+
+    it(`${controller.name} can match the results of another query`, async () => {
+      const ParentObject = Parse.Object.extend('ParentObject');
+      const ChildObject = Parse.Object.extend('ChildObject');
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        objects.push(new ParentObject({
+          child: new ChildObject({x: i, qtest: true}),
+          x: 10 + i,
+        }));
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const subQuery = new Parse.Query(ChildObject);
+      subQuery.equalTo('qtest', true);
+      subQuery.greaterThan('x', 5);
+      const q = new Parse.Query(ParentObject);
+      q.matchesQuery('child', subQuery);
+      q.fromLocalDatastore();
+      const results = await q.find();
+
+      assert.equal(results.length, 4);
+      results.forEach((o) => {
+        assert(o.get('x') > 15);
+      });
+    });
+
+    it(`${controller.name} can not match the results of another query`, async () => {
+      const ParentObject = Parse.Object.extend('ParentObject');
+      const ChildObject = Parse.Object.extend('ChildObject');
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        objects.push(new ParentObject({
+          child: new ChildObject({x: i, dneqtest: true}),
+          dneqtest: true,
+          x: 10 + i,
+        }));
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const subQuery = new Parse.Query(ChildObject);
+      subQuery.equalTo('dneqtest', true);
+      subQuery.greaterThan('x', 5);
+      const q = new Parse.Query(ParentObject);
+      q.equalTo('dneqtest', true);
+      q.doesNotMatchQuery('child', subQuery);
+      q.fromLocalDatastore();
+      const results = await q.find();
+
+      assert.equal(results.length, 6);
+      results.forEach((o) => {
+        assert(o.get('x') >= 10);
+        assert(o.get('x') <= 15);
+      });
+    });
+
+    it(`${controller.name} can select keys from a matched query`, async () => {
+      const Restaurant = Parse.Object.extend('Restaurant');
+      const Person = Parse.Object.extend('Person');
+      const objects = [
+        new Restaurant({ rating: 5, location: 'Djibouti' }),
+        new Restaurant({ rating: 3, location: 'Ouagadougou' }),
+        new Person({ name: 'Bob', hometown: 'Djibouti' }),
+        new Person({ name: 'Tom', hometown: 'Ouagadougou' }),
+        new Person({ name: 'Billy', hometown: 'Detroit' }),
+      ];
+
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      let query = new Parse.Query(Restaurant);
+      query.greaterThan('rating', 4);
+      let mainQuery = new Parse.Query(Person);
+      mainQuery.matchesKeyInQuery('hometown', 'location', query);
+      mainQuery.fromLocalDatastore();
+      let results = await mainQuery.find();
+
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('name'), 'Bob');
+
+      query = new Parse.Query(Restaurant);
+      query.greaterThan('rating', 4);
+      mainQuery = new Parse.Query(Person);
+      mainQuery.doesNotMatchKeyInQuery('hometown', 'location', query);
+      mainQuery.ascending('name');
+      mainQuery.fromLocalDatastore();
+      results = await mainQuery.find();
+
+      assert.equal(results.length, 2);
+      assert(['Billy', 'Tom'].includes(results[0].get('name')));
+      assert(['Billy', 'Tom'].includes(results[1].get('name')));
+    });
+
+    it(`${controller.name} supports objects with length`, async () => {
+      const obj = new TestObject();
+      obj.set('length', 5);
+      assert.equal(obj.get('length'), 5);
+      await obj.save();
+      await obj.pin();
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('objectId', obj.id);
+      query.fromLocalDatastore();
+      const results = await query.find();
+
+      assert.equal(results.length, 1);
+      assert.equal(results[0].get('length'), 5);
+    });
+
+    it(`${controller.name} can include User fields`, async () => {
+      const user = await Parse.User.signUp('bob', 'password', { age: 21 });
+      const obj = new TestObject();
+      await obj.save({ owner: user });
+      await obj.pin();
+
+      const query = new Parse.Query(TestObject);
+      query.include('owner');
+      query.fromLocalDatastore();
+      const objAgain = await query.get(obj.id);
+
+      assert(objAgain.get('owner') instanceof Parse.User);
+      assert.equal(objAgain.get('owner').get('age'), 21);
+      try {
+        await Parse.User.logOut();
+      } catch(e) { /* */ }
+    });
+
+    it(`${controller.name} can build OR queries`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        const obj = new Parse.Object('BoxedNumber');
+        obj.set({ x: i, orquery: true });
+        objects.push(obj);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const q1 = new Parse.Query('BoxedNumber');
+      q1.equalTo('orquery', true);
+      q1.lessThan('x', 2);
+
+      const q2 = new Parse.Query('BoxedNumber');
+      q2.equalTo('orquery', true);
+      q2.greaterThan('x', 5);
+
+      const orQuery = Parse.Query.or(q1, q2);
+      orQuery.fromLocalDatastore();
+      const results = await orQuery.find();
+      assert.equal(results.length, 6);
+      results.forEach((number) => {
+        assert(number.get('x') < 2 || number.get('x') > 5);
+      });
+    });
+
+    it(`${controller.name} can build complex OR queries`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        const child = new Parse.Object('Child');
+        child.set('x', i);
+        child.set('complexor', true);
+        const parent = new Parse.Object('Parent');
+        parent.set('child', child);
+        parent.set('complexor', true);
+        parent.set('y', i);
+        objects.push(parent);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const subQuery = new Parse.Query('Child');
+      subQuery.equalTo('x', 4);
+      subQuery.equalTo('complexor', true);
+
+      const q1 = new Parse.Query('Parent');
+      q1.matchesQuery('child', subQuery);
+
+      const q2 = new Parse.Query('Parent');
+      q2.equalTo('complexor', true);
+      q2.lessThan('y', 2);
+
+      const orQuery = Parse.Query.or(q1, q2);
+      orQuery.fromLocalDatastore();
+      const results = await  orQuery.find();
+      assert.equal(results.length, 3);
+    });
+
+    it(`${controller.name} can build AND queries`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        const obj = new Parse.Object('BoxedNumber');
+        obj.set({ x: i, and: true });
+        objects.push(obj);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const q1 = new Parse.Query('BoxedNumber');
+      q1.equalTo('and', true);
+      q1.greaterThan('x', 2);
+
+      const q2 = new Parse.Query('BoxedNumber');
+      q2.equalTo('and', true);
+      q2.lessThan('x', 5);
+
+      const andQuery = Parse.Query.and(q1, q2);
+      andQuery.fromLocalDatastore();
+      const results = await andQuery.find();
+      assert.equal(results.length, 2);
+      results.forEach((number) => {
+        assert(number.get('x') > 2 && number.get('x') < 5);
+      });
+    });
+
+    it(`${controller.name} can build complex AND queries`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i++) {
+        const child = new Parse.Object('Child');
+        child.set('x', i);
+        child.set('and', true);
+        const parent = new Parse.Object('Parent');
+        parent.set('child', child);
+        parent.set('and', true);
+        parent.set('y', i);
+        objects.push(parent);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const subQuery = new Parse.Query('Child');
+      subQuery.equalTo('x', 4);
+      subQuery.equalTo('and', true);
+
+      const q1 = new Parse.Query('Parent');
+      q1.matchesQuery('child', subQuery);
+
+      const q2 = new Parse.Query('Parent');
+      q2.equalTo('and', true);
+      q2.equalTo('y', 4);
+
+      const andQuery = Parse.Query.and(q1, q2);
+      andQuery.fromLocalDatastore();
+      const results = await andQuery.find();
+      assert.equal(results.length, 1);
+    });
+
+    it(`${controller.name} can build NOR queries`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i += 1) {
+        const obj = new Parse.Object('NORTest');
+        obj.set({ x: i });
+        objects.push(obj);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const q1 = new Parse.Query('NORTest');
+      q1.greaterThan('x', 5);
+      const q2 = new Parse.Query('NORTest');
+      q2.lessThan('x', 3);
+      const norQuery = Parse.Query.nor(q1, q2);
+      norQuery.fromLocalDatastore();
+      const results = await norQuery.find();
+
+      assert.equal(results.length, 3);
+      results.forEach((number) => {
+        assert(number.get('x') >= 3 && number.get('x') <= 5);
+      });
+    });
+
+    it(`${controller.name} can build complex NOR queries`, async () => {
+      const objects = [];
+      for (let i = 0; i < 10; i += 1) {
+        const child = new Parse.Object('Child');
+        child.set('x', i);
+        const parent = new Parse.Object('Parent');
+        parent.set('child', child);
+        parent.set('y', i);
+        objects.push(parent);
+      }
+      await Parse.Object.saveAll(objects);
+      await Parse.Object.pinAll(objects);
+
+      const subQuery = new Parse.Query('Child');
+      subQuery.equalTo('x', 4);
+      const q1 = new Parse.Query('Parent');
+      q1.matchesQuery('child', subQuery);
+      const q2 = new Parse.Query('Parent');
+      q2.equalTo('y', 5);
+      const norQuery = Parse.Query.nor(q1, q2);
+      norQuery.fromLocalDatastore();
+      const results = await norQuery.find();
+
+      assert.equal(results.length, 8);
+      results.forEach((number) => {
+        assert(number.get('x') !== 4 || number.get('x') !== 5);
+      });
+    });
+
+    it(`${controller.name} can iterate over results with each`, async () => {
+      const items = [];
+      for (let i = 0; i < 50; i++) {
+        items.push(new TestObject({ x: i, eachtest: true }));
+      }
+      const seen = [];
+      await Parse.Object.saveAll(items);
+      await Parse.Object.pinAll(items);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('eachtest', true);
+      query.lessThan('x', 25);
+      query.fromLocalDatastore();
+      await query.each((obj) => {
+        seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1;
+      });
+      assert.equal(seen.length, 25);
+      for (let i = 0; i < seen.length; i++) {
+        assert.equal(seen[i], 1);
+      }
+    });
+
+    it(`${controller.name} fails query.each with order`, async () => {
+      const items = [];
+      for (let i = 0; i < 50; i++) {
+        items.push(new TestObject({ x: i, eachtest: true }));
+      }
+      const seen = [];
+      await Parse.Object.saveAll(items);
+      await Parse.Object.pinAll(items);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('eachtest', true);
+      query.lessThan('x', 25);
+      query.ascending('x');
+      query.fromLocalDatastore();
+      try {
+        await query.each((obj) => {
+          seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1;
+        });
+      } catch (e) {
+        assert.equal(e, 'Cannot iterate on a query with sort, skip, or limit.');
+      }
+    });
+
+    it(`${controller.name} fails query.each with limit`, async () => {
+      const items = [];
+      for (let i = 0; i < 50; i++) {
+        items.push(new TestObject({ x: i, eachtest: true }));
+      }
+      const seen = [];
+      await Parse.Object.saveAll(items);
+      await Parse.Object.pinAll(items);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('eachtest', true);
+      query.lessThan('x', 25);
+      query.limit(20);
+      query.fromLocalDatastore();
+      try {
+        await query.each((obj) => {
+          seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1;
+        });
+      } catch (e) {
+        assert.equal(e, 'Cannot iterate on a query with sort, skip, or limit.');
+      }
+    });
+
+    it(`${controller.name} fails query.each with skip`, async () => {
+      const items = [];
+      for (let i = 0; i < 50; i++) {
+        items.push(new TestObject({ x: i, eachtest: true }));
+      }
+      const seen = [];
+      await Parse.Object.saveAll(items);
+      await Parse.Object.pinAll(items);
+
+      const query = new Parse.Query(TestObject);
+      query.equalTo('eachtest', true);
+      query.lessThan('x', 25);
+      query.skip(20);
+      query.fromLocalDatastore();
+      try {
+        await query.each((obj) => {
+          seen[obj.get('x')] = (seen[obj.get('x')] || 0) + 1;
+        });
+      } catch (e) {
+        assert.equal(e, 'Cannot iterate on a query with sort, skip, or limit.');
+      }
+    });
+
+    it(`${controller.name} can select specific keys`, async () => {
+      const obj = new TestObject({ foo: 'baz', bar: 1 });
+      await obj.save();
+      await obj.pin();
+
+      const q = new Parse.Query(TestObject);
+      q.equalTo('objectId', obj.id);
+      q.select('foo');
+      q.fromLocalDatastore();
+      const result = await q.first();
+
+      assert(result.id);
+      assert(result.createdAt);
+      assert(result.updatedAt);
+      assert(!result.dirty());
+      assert.equal(result.get('foo'), 'baz');
+      assert.equal(result.get('bar'), undefined);
+    });
+
+    it(`${controller.name} can select specific keys with each`, async () => {
+      const obj = new TestObject({ foo: 'baz', bar: 1 });
+      await obj.save();
+      await obj.pin();
+
+      const q = new Parse.Query(TestObject);
+      q.equalTo('objectId', obj.id);
+      q.select('foo');
+      q.fromLocalDatastore();
+      await q.each((o) => {
+        assert(o.id);
+        assert.equal(o.get('foo'), 'baz');
+        assert.equal(o.get('bar'), undefined);
+      });
+    });
+
+    it('supports withinPolygon', async () => {
+      const sacramento = new TestObject();
+      sacramento.set('location', new Parse.GeoPoint(38.52, -121.50));
+      sacramento.set('name', 'Sacramento');
+
+      const honolulu = new TestObject();
+      honolulu.set('location', new Parse.GeoPoint(21.35, -157.93));
+      honolulu.set('name', 'Honolulu');
+
+      const sf = new TestObject();
+      sf.set('location', new Parse.GeoPoint(37.75, -122.68));
+      sf.set('name', 'San Francisco');
+
+      await Parse.Object.saveAll([sacramento, honolulu, sf]);
+      await Parse.Object.pinAll([sacramento, honolulu, sf]);
+
+      const points = [
+        new Parse.GeoPoint(37.85, -122.33),
+        new Parse.GeoPoint(37.85, -122.90),
+        new Parse.GeoPoint(37.68, -122.90),
+        new Parse.GeoPoint(37.68, -122.33)
+      ];
+      const query = new Parse.Query(TestObject);
+      query.withinPolygon('location', points);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 1);
+    });
+
+    it('supports polygonContains', async () => {
+      const p1 = [[0,0], [0,1], [1,1], [1,0]];
+      const p2 = [[0,0], [0,2], [2,2], [2,0]];
+      const p3 = [[10,10], [10,15], [15,15], [15,10], [10,10]];
+
+      const polygon1 = new Parse.Polygon(p1);
+      const polygon2 = new Parse.Polygon(p2);
+      const polygon3 = new Parse.Polygon(p3);
+
+      const obj1 = new TestObject({ polygon: polygon1 });
+      const obj2 = new TestObject({ polygon: polygon2 });
+      const obj3 = new TestObject({ polygon: polygon3 });
+
+      await Parse.Object.saveAll([obj1, obj2, obj3]);
+      await Parse.Object.pinAll([obj1, obj2, obj3]);
+
+      const point = new Parse.GeoPoint(0.5, 0.5);
+      const query = new Parse.Query(TestObject);
+      query.polygonContains('polygon', point);
+      query.fromLocalDatastore();
+      const results = await query.find();
+      assert.equal(results.length, 2);
+    });
+  });
+}
+
+describe('Parse LocalDatastore', () => {
+  beforeEach((done) => {
+    Parse.initialize('integration', null, 'notsosecret');
+    Parse.CoreManager.set('SERVER_URL', 'http://localhost:1337/parse');
+    Parse.enableLocalDatastore();
+    Parse.Storage._clear();
+    clear().then(() => {
+      done()
+    });
+  });
+
+  const controllers = [
+    { name: 'Default', file: '../../lib/node/LocalDatastoreController.default' },
+    { name: 'Browser', file: '../../lib/node/LocalDatastoreController.browser' },
+    { name: 'React-Native', file: '../../lib/node/LocalDatastoreController.react-native' },
+  ];
+
+  for (let i = 0; i < controllers.length; i += 1) {
+    const controller = controllers[i];
+    runTest(controller);
+  }
+});
diff --git a/integration/test/mockLocalStorage.js b/integration/test/mockLocalStorage.js
new file mode 100644
index 0000000..eadaf97
--- /dev/null
+++ b/integration/test/mockLocalStorage.js
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+
+let mockStorage = {};
+const mockLocalStorage = {
+
+  getItem(path) {
+    return mockStorage[path] || null;
+  },
+
+  setItem(path, value) {
+    mockStorage[path] = value;
+  },
+
+  removeItem(path) {
+    delete mockStorage[path];
+  },
+
+  get length() {
+    return Object.keys(mockStorage).length;
+  },
+
+  key: (i) => {
+    const keys = Object.keys(mockStorage);
+    return keys[i] || null;
+  },
+
+  clear() {
+    mockStorage = {};
+  },
+};
+
+module.exports = mockLocalStorage;
diff --git a/integration/test/mockRNStorage.js b/integration/test/mockRNStorage.js
new file mode 100644
index 0000000..4e09cfa
--- /dev/null
+++ b/integration/test/mockRNStorage.js
@@ -0,0 +1,35 @@
+/**
+ * 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.
+ */
+
+let mockStorage = {};
+const mockRNStorage = {
+  getItem(path, cb) {
+    cb(undefined, mockStorage[path] || null);
+  },
+
+  setItem(path, value, cb) {
+    mockStorage[path] = value;
+    cb();
+  },
+
+  removeItem(path, cb) {
+    delete mockStorage[path];
+    cb();
+  },
+
+  getAllKeys(cb) {
+    cb(undefined, Object.keys(mockStorage));
+  },
+
+  clear() {
+    mockStorage = {};
+  },
+};
+
+module.exports = mockRNStorage;
diff --git a/src/__tests__/CoreManager-test.js b/src/__tests__/CoreManager-test.js
index a57fc7c..739366d 100644
--- a/src/__tests__/CoreManager-test.js
+++ b/src/__tests__/CoreManager-test.js
@@ -354,4 +354,31 @@ describe('CoreManager', () => {
     CoreManager.setSchemaController(controller);
     expect(CoreManager.getSchemaController()).toBe(controller);
   });
+
+  it('requires LocalDatastoreController to implement certain functionality', () => {
+    expect(CoreManager.setLocalDatastoreController.bind(null, {})).toThrow(
+      'LocalDatastoreController must implement pinWithName()'
+    );
+
+    expect(CoreManager.setLocalDatastoreController.bind(null, {
+      fromPinWithName: function() {},
+      pinWithName: function() {},
+      unPinWithName: function() {},
+      getAllContents: function() {},
+      clear: function() {}
+    })).not.toThrow();
+  });
+
+  it('can set and get setLocalDatastoreController', () => {
+    const controller = {
+      fromPinWithName: function() {},
+      pinWithName: function() {},
+      unPinWithName: function() {},
+      getAllContents: function() {},
+      clear: function() {}
+    };
+
+    CoreManager.setLocalDatastoreController(controller);
+    expect(CoreManager.getLocalDatastoreController()).toBe(controller);
+  });
 });
diff --git a/src/__tests__/LocalDatastore-test.js b/src/__tests__/LocalDatastore-test.js
new file mode 100644
index 0000000..8ca859e
--- /dev/null
+++ b/src/__tests__/LocalDatastore-test.js
@@ -0,0 +1,728 @@
+/**
+ * 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.
+ */
+
+jest.autoMockOff();
+
+const encode = require('../encode').default;
+
+let objectCount = 0;
+
+class MockObject {
+  constructor (className) {
+    this.className = className;
+    this.attributes = {};
+
+    this.id = String(objectCount++);
+  }
+
+  toJSON() {
+    return this.attributes;
+  }
+
+  _getServerData() {
+    return this._serverData;
+  }
+
+  toPointer() {
+    return 'POINTER';
+  }
+
+  dirty() {}
+
+  _toFullJSON() {
+    const json = {
+      __type: 'Object',
+      className: this.className
+    };
+    for (const attr in this.attributes) {
+      if (this.attributes[attr].id) {
+        json[attr] = this.attributes[attr]._toFullJSON();
+      } else {
+        json[attr] = encode(this.attributes[attr], false, true, null);
+      }
+    }
+    if (this.id) {
+      json.objectId = this.id;
+    }
+    return json;
+  }
+
+  fromJSON(json) {
+    const o = new MockObject(json.className);
+    o.id = json.objectId;
+    for (const attr in json) {
+      if (attr !== 'className' && attr !== '__type' && attr !== 'objectId') {
+        o.attributes[attr] = json[attr];
+      }
+    }
+    return o;
+  }
+
+  _getId() {
+    return this.id;
+  }
+
+  set(key, value) {
+    this.attributes[key] = value;
+  }
+}
+
+const mockAsyncStorage = require('./test_helpers/mockRNStorage');
+const mockLocalStorageController = {
+  fromPinWithName: jest.fn(),
+  pinWithName: jest.fn(),
+  unPinWithName: jest.fn(),
+  getAllContents: jest.fn(),
+  clear: jest.fn(),
+};
+jest.setMock('../ParseObject', MockObject);
+const CoreManager = require('../CoreManager');
+const LocalDatastore = require('../LocalDatastore');
+const ParseObject = require('../ParseObject');
+const RNDatastoreController = require('../LocalDatastoreController.react-native');
+const BrowserDatastoreController = require('../LocalDatastoreController.browser');
+const DefaultDatastoreController = require('../LocalDatastoreController.default');
+
+const mockLocalStorage = require('./test_helpers/mockLocalStorage');
+
+global.localStorage = mockLocalStorage;
+
+describe('LocalDatastore', () => {
+  beforeEach(() => {
+    CoreManager.setLocalDatastoreController(mockLocalStorageController);
+    jest.clearAllMocks();
+    LocalDatastore.isEnabled = true;
+  });
+
+  it('isEnabled', () => {
+    LocalDatastore.isEnabled = true;
+    const isEnabled = LocalDatastore.checkIfEnabled();
+    expect(isEnabled).toBe(true);
+  });
+
+  it('isDisabled', () => {
+    const spy = jest.spyOn(console, 'log');
+    LocalDatastore.isEnabled = false;
+    const isEnabled = LocalDatastore.checkIfEnabled();
+    expect(isEnabled).toBe(false);
+    LocalDatastore._updateLocalIdForObject('', null);
+    LocalDatastore._destroyObjectIfPinned(null);
+    LocalDatastore._updateObjectIfPinned(null);
+    expect(spy).toHaveBeenCalledWith('Parse.enableLocalDatastore() must be called first');
+    spy.mockRestore();
+  });
+
+  it('can clear', async () => {
+    await LocalDatastore._clear();
+    expect(mockLocalStorageController.clear).toHaveBeenCalledTimes(1);
+  });
+
+  it('can getAllContents', async () => {
+    await LocalDatastore._getAllContents();
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+  });
+
+  it('_handlePinWithName no children', async () => {
+    const object = new ParseObject('Item');
+    await LocalDatastore._handlePinWithName('test_pin', object);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(2);
+  });
+
+  it('_handlePinWithName default pin', async () => {
+    const object = new ParseObject('Item');
+    await LocalDatastore._handlePinWithName(LocalDatastore.DEFAULT_PIN, object);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(2);
+  });
+
+  it('_handlePinWithName unsaved children', async () => {
+    const parent = new ParseObject('Item');
+    const unsaved = { className: 'Item', __type: 'Object' };
+    parent.set('child', unsaved);
+    await LocalDatastore._handlePinWithName('test_pin', parent);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(2);
+  });
+
+  it('_handlePinWithName with children', async () => {
+    const parent = new ParseObject('Item');
+    const child = new ParseObject('Item');
+    const grandchild = new ParseObject('Item');
+    child.set('grandchild', grandchild);
+    parent.set('child', child);
+    await LocalDatastore._handlePinWithName('test_pin', parent);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(4);
+  });
+
+  it('_handleUnPinWithName default pin', async () => {
+    const object = new ParseObject('Item');
+    const LDS = {
+      [LocalDatastore.DEFAULT_PIN]: [`Item_${object._getId()}`, '1234'],
+    };
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+    await LocalDatastore._handleUnPinWithName(LocalDatastore.DEFAULT_PIN, object);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledWith(LocalDatastore.DEFAULT_PIN, ['1234']);
+  });
+
+  it('_handleUnPinWithName specific pin', async () => {
+    const object = new ParseObject('Item');
+    const LDS = {
+      parsePin_test_pin: [`Item_${object._getId()}`, '1234'],
+    };
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+    await LocalDatastore._handleUnPinWithName('test_pin', object);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledWith(LocalDatastore.PIN_PREFIX + 'test_pin', ['1234']);
+  });
+
+  it('_handleUnPinWithName default pin remove pinName', async () => {
+    const object = new ParseObject('Item');
+    const LDS = {
+      [LocalDatastore.DEFAULT_PIN]: [],
+    };
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+    await LocalDatastore._handleUnPinWithName(LocalDatastore.DEFAULT_PIN, object);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledTimes(2);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledWith(LocalDatastore.DEFAULT_PIN);
+  });
+
+  it('_handleUnPinWithName specific pin remove pinName', async () => {
+    const object = new ParseObject('Item');
+    const LDS = {
+      [LocalDatastore.PIN_PREFIX + 'test_pin']: [],
+    };
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+    await LocalDatastore._handleUnPinWithName('test_pin', object);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledTimes(2);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledWith(LocalDatastore.PIN_PREFIX + 'test_pin');
+  });
+
+  it('_handleUnPinWithName remove if exist', async () => {
+    const obj1 = new ParseObject('Item');
+    const obj2 = new ParseObject('Item');
+    const obj3 = new ParseObject('Item');
+    const objects = [`Item_${obj1.id}`, `Item_${obj2.id}`, `Item_${obj3.id}`];
+    const LDS = {
+      [LocalDatastore.PIN_PREFIX + 'test_pin']: objects,
+      [LocalDatastore.PIN_PREFIX + 'test_pin_2']: objects,
+      [`Item_${obj1.id}`]: obj1._toFullJSON(),
+      [`Item_${obj2.id}`]: obj2._toFullJSON(),
+    }
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    await LocalDatastore._handleUnPinWithName('test_pin', obj1);
+
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledWith(LocalDatastore.PIN_PREFIX + 'test_pin', [`Item_${obj2.id}`, `Item_${obj3.id}`]);
+  });
+
+  it('_updateObjectIfPinned not pinned', async () => {
+    const object = new ParseObject('Item');
+    LocalDatastore.isEnabled = true;
+    LocalDatastore._updateObjectIfPinned(object);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(0);
+  });
+
+  it('_updateObjectIfPinned if pinned', async () => {
+    const object = new ParseObject('Item');
+    mockLocalStorageController
+      .fromPinWithName
+      .mockImplementationOnce(() => [object]);
+
+    LocalDatastore.isEnabled = true;
+    await LocalDatastore._updateObjectIfPinned(object);
+
+    expect(mockLocalStorageController.fromPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.fromPinWithName.mock.results[0].value).toEqual([object]);
+
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledWith(`Item_${object.id}`, object._toFullJSON());
+  });
+
+  it('_updateLocalIdForObject not pinned', async () => {
+    const object = new ParseObject('Item');
+    object.id = '1234'
+    await LocalDatastore._updateLocalIdForObject('local0', object);
+    expect(mockLocalStorageController.fromPinWithName.mock.results[0].value).toEqual(undefined);
+  });
+
+  it('_updateLocalIdForObject if pinned', async () => {
+    const object = new ParseObject('Item');
+    const json = object._toFullJSON();
+    const localId = 'local' + object.id;
+    const localKey = `Item_${localId}`;
+    const LDS = {
+      [LocalDatastore.DEFAULT_PIN]: [localKey],
+      [localKey]: json,
+    };
+    mockLocalStorageController
+      .fromPinWithName
+      .mockImplementationOnce(() => json);
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    await LocalDatastore._updateLocalIdForObject(localId, object);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(2);
+  });
+
+  it('_updateLocalIdForObject if pinned with name', async () => {
+    const object = new ParseObject('Item');
+    const json = object._toFullJSON();
+    const localId = 'local' + object.id;
+    const localKey = `Item_${localId}`;
+    const LDS = {
+      [LocalDatastore.PIN_PREFIX + 'test_pin']: [localKey],
+      [localKey]: json,
+    };
+    mockLocalStorageController
+      .fromPinWithName
+      .mockImplementationOnce(() => json);
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    await LocalDatastore._updateLocalIdForObject(localId, object);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(2);
+  });
+
+  it('_updateLocalIdForObject if pinned with new name', async () => {
+    const object = new ParseObject('Item');
+    const json = object._toFullJSON();
+    const localId = 'local' + object.id;
+    const LDS = {
+      [LocalDatastore.DEFAULT_PIN]: [object.id],
+      [object.id]: json,
+    };
+    mockLocalStorageController
+      .fromPinWithName
+      .mockImplementationOnce(() => json);
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    await LocalDatastore._updateLocalIdForObject(localId, object);
+
+    expect(mockLocalStorageController.fromPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.fromPinWithName.mock.results[0].value).toEqual(json);
+
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledWith(`Item_${localId}`);
+
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledWith(`Item_${object.id}`, json);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+  });
+
+  it('_serializeObjectsFromPinName no name returns all objects', async () => {
+    const object = new ParseObject('Item');
+    const json = object._toFullJSON();
+    const LDS = {
+      [LocalDatastore.DEFAULT_PIN]: [object.id],
+      [object.id]: json,
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    const results = await LocalDatastore._serializeObjectsFromPinName(null);
+    expect(results).toEqual([json]);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+  });
+
+  it('_serializeObjectsFromPinName no objects', async () => {
+    const object = new ParseObject('Item');
+    const json = object._toFullJSON();
+    const LDS = {
+      [LocalDatastore.DEFAULT_PIN]: [object.id, 'local10', 'local11'],
+      [object.id]: json,
+      randomName: [object.id],
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    const results = await LocalDatastore._serializeObjectsFromPinName(LocalDatastore.DEFAULT_PIN);
+    expect(results).toEqual([]);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+  });
+
+  it('_serializeObjectsFromPinName with name', async () => {
+    const obj1 = new ParseObject('Item');
+    const obj2 = new ParseObject('Item');
+    const obj3 = new ParseObject('Item');
+
+    const LDS = {
+      [obj1.id]: obj1._toFullJSON(),
+      [obj2.id]: obj2._toFullJSON(),
+      [obj3.id]: obj3._toFullJSON(),
+      testPin: [obj1.id, obj2.id, obj3.id],
+    };
+
+    mockLocalStorageController
+      .fromPinWithName
+      .mockImplementationOnce(() => LDS.testPin)
+      .mockImplementationOnce(() => LDS[obj1.id])
+      .mockImplementationOnce(() => LDS[obj2.id])
+      .mockImplementationOnce(() => LDS[obj3.id]);
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    const results = await LocalDatastore._serializeObjectsFromPinName('testPin');
+    expect(results).toEqual([obj1._toFullJSON(), obj2._toFullJSON(), obj3._toFullJSON()]);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+
+    expect(mockLocalStorageController.fromPinWithName).toHaveBeenCalledTimes(4);
+  });
+
+  it('_serializeObject no children', async () => {
+    const object = new ParseObject('Item');
+    object.id = 1234;
+    const json = object._toFullJSON();
+    const objectKey = `Item_1234`;
+    const LDS = {
+      [LocalDatastore.DEFAULT_PIN]: [objectKey],
+      [objectKey]: json,
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    const result = await LocalDatastore._serializeObject(objectKey);
+    expect(result).toEqual(json);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+  });
+
+  it('_serializeObject object does not exist', async () => {
+    const object = new ParseObject('Item');
+    object.id = 1234;
+    const objectKey = `Item_1234`;
+    const LDS = {};
+
+    const result = await LocalDatastore._serializeObject(objectKey, LDS);
+    expect(result).toEqual(null);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(0);
+  });
+
+  it('_serializeObject with children', async () => {
+    const object = new ParseObject('Item');
+    object.id = 1234;
+    const child = new ParseObject('Item');
+    child.id = 5678;
+    object.set('child', child);
+    const newData = child._toFullJSON();
+    newData.field = 'Serialize Me';
+    const LDS = {
+      [LocalDatastore.DEFAULT_PIN]: [`Item_${object.id}`, `Item_${child.id}`],
+      [`Item_${object.id}`]: object._toFullJSON(),
+      [`Item_${child.id}`]: newData,
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    const expectedResults = object._toFullJSON();
+    expectedResults.child = newData;
+    const result = await LocalDatastore._serializeObject(`Item_${object.id}`);
+    expect(result).toEqual(expectedResults);
+  });
+
+  it('_destroyObjectIfPinned no objects found in pinName', async () => {
+    const object = new ParseObject('Item');
+    let LDS = {};
+    LocalDatastore.isEnabled = true;
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    await LocalDatastore._destroyObjectIfPinned(object);
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledTimes(0);
+
+    jest.clearAllMocks();
+
+    const obj1 = new ParseObject('Item');
+    const obj2 = new ParseObject('Item');
+
+    LDS = {
+      [`Item_${obj1.id}`]: obj1._toFullJSON(),
+      [`Item_${obj2.id}`]: obj2._toFullJSON(),
+      [LocalDatastore.DEFAULT_PIN]: [],
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    LocalDatastore.isEnabled = true;
+    await LocalDatastore._destroyObjectIfPinned(obj1);
+
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledWith(`Item_${obj1.id}`);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(0);
+  });
+
+  it('_destroyObjectIfPinned no objects found in pinName remove pinName', async () => {
+    const obj1 = new ParseObject('Item');
+    const obj2 = new ParseObject('Item');
+
+    const LDS = {
+      [`Item_${obj1.id}`]: obj1._toFullJSON(),
+      [`Item_${obj2.id}`]: obj2._toFullJSON(),
+      [LocalDatastore.PIN_PREFIX + 'Custom_Pin']: [`Item_${obj2.id}`],
+      [LocalDatastore.DEFAULT_PIN]: [`Item_${obj2.id}`],
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    LocalDatastore.isEnabled = true;
+    await LocalDatastore._destroyObjectIfPinned(obj2);
+
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledTimes(3);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledWith(`Item_${obj2.id}`);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(0);
+  });
+
+  it('_destroyObjectIfPinned', async () => {
+    const obj1 = new ParseObject('Item');
+    const obj2 = new ParseObject('Item');
+
+    const LDS = {
+      [`Item_${obj1.id}`]: obj1._toFullJSON(),
+      [`Item_${obj2.id}`]: obj2._toFullJSON(),
+      [LocalDatastore.DEFAULT_PIN]: [`Item_${obj1.id}`, `Item_${obj2.id}`],
+    };
+
+    mockLocalStorageController
+      .getAllContents
+      .mockImplementationOnce(() => LDS);
+
+    LocalDatastore.isEnabled = true;
+    await LocalDatastore._destroyObjectIfPinned(obj1);
+
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.unPinWithName).toHaveBeenCalledWith(`Item_${obj1.id}`);
+
+    expect(mockLocalStorageController.getAllContents).toHaveBeenCalledTimes(1);
+
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalStorageController.pinWithName).toHaveBeenCalledWith(LocalDatastore.DEFAULT_PIN, [`Item_${obj2.id}`]);
+  });
+
+  it('_traverse', () => {
+    // Skip if no objectId
+    let object = {}
+    let encountered = {};
+    LocalDatastore._traverse(object, encountered);
+    expect(encountered).toEqual({});
+
+    // Set Encountered
+    object = { objectId: 1234, className: 'Item' };
+    encountered = {};
+    LocalDatastore._traverse(object, encountered);
+    expect(encountered).toEqual({ 'Item_1234': object });
+
+    // Skip if already encountered
+    object = { objectId: 1234, className: 'Item' };
+    encountered = { 'Item_1234': object };
+    LocalDatastore._traverse(object, encountered);
+    expect(encountered).toEqual({ 'Item_1234': object });
+
+    // Test if null field exist still encounter
+    object = { objectId: 1234, className: 'Item', field: null };
+    encountered = {};
+    LocalDatastore._traverse(object, encountered);
+    expect(encountered).toEqual({ 'Item_1234': object });
+  });
+});
+
+describe('BrowserDatastoreController', async () => {
+  beforeEach(async () => {
+    await BrowserDatastoreController.clear();
+  });
+
+  it('implement functionality', () => {
+    expect(typeof BrowserDatastoreController.fromPinWithName).toBe('function');
+    expect(typeof BrowserDatastoreController.pinWithName).toBe('function');
+    expect(typeof BrowserDatastoreController.unPinWithName).toBe('function');
+    expect(typeof BrowserDatastoreController.getAllContents).toBe('function');
+    expect(typeof BrowserDatastoreController.clear).toBe('function');
+  });
+
+  it('can store and retrieve values', async () => {
+    expect(await BrowserDatastoreController.fromPinWithName('myKey')).toEqual(null);
+    await BrowserDatastoreController.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await BrowserDatastoreController.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+  });
+
+  it('can remove values', async () => {
+    await BrowserDatastoreController.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await BrowserDatastoreController.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    await BrowserDatastoreController.unPinWithName('myKey');
+    expect(await BrowserDatastoreController.fromPinWithName('myKey')).toEqual(null);
+  });
+});
+
+describe('DefaultDatastoreController', () => {
+  beforeEach(async () => {
+    await DefaultDatastoreController.clear();
+  });
+
+  it('implement functionality', () => {
+    expect(typeof DefaultDatastoreController.fromPinWithName).toBe('function');
+    expect(typeof DefaultDatastoreController.pinWithName).toBe('function');
+    expect(typeof DefaultDatastoreController.unPinWithName).toBe('function');
+    expect(typeof DefaultDatastoreController.getAllContents).toBe('function');
+    expect(typeof DefaultDatastoreController.clear).toBe('function');
+  });
+
+  it('can store and retrieve values', async () => {
+    expect(await DefaultDatastoreController.fromPinWithName('myKey')).toEqual(null);
+    await DefaultDatastoreController.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await DefaultDatastoreController.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    expect(await DefaultDatastoreController.getAllContents()).toEqual({ myKey: [ { name: 'test' } ] });
+  });
+
+  it('can remove values', async () => {
+    await DefaultDatastoreController.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await DefaultDatastoreController.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    await DefaultDatastoreController.unPinWithName('myKey');
+    expect(await DefaultDatastoreController.fromPinWithName('myKey')).toEqual(null);
+    expect(await DefaultDatastoreController.getAllContents()).toEqual({});
+  });
+});
+
+describe('LocalDatastore (BrowserDatastoreController)', () => {
+  beforeEach(async () => {
+    CoreManager.setLocalDatastoreController(BrowserDatastoreController);
+    await LocalDatastore._clear();
+  });
+
+  it('can store and retrieve values', async () => {
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual(null);
+    await LocalDatastore.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    expect(await LocalDatastore._getAllContents()).toEqual({ myKey: [ { name: 'test' } ] });
+  });
+
+  it('can remove values', async () => {
+    await LocalDatastore.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    await LocalDatastore.unPinWithName('myKey');
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual(null);
+    expect(await LocalDatastore._getAllContents()).toEqual({});
+  });
+
+  it('can handle store error', async () => {
+    const mockStorageError = {
+      setItem() {
+        throw new Error('error thrown');
+      },
+    };
+    Object.defineProperty(window, 'localStorage', { // eslint-disable-line
+      value: mockStorageError
+    });
+    try {
+      await LocalDatastore.pinWithName('myKey', [{ name: 'test' }]);
+    } catch (e) {
+      expect(e.message).toBe('error thrown');
+    }
+  });
+});
+
+describe('LocalDatastore (DefaultDatastoreController)', () => {
+  beforeEach(async () => {
+    CoreManager.setLocalDatastoreController(DefaultDatastoreController);
+    await LocalDatastore._clear();
+  });
+
+  it('can store and retrieve values', async () => {
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual(null);
+    await LocalDatastore.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    expect(await LocalDatastore._getAllContents()).toEqual({ myKey: [ { name: 'test' } ] });
+  });
+
+  it('can remove values', async () => {
+    await LocalDatastore.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    await LocalDatastore.unPinWithName('myKey');
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual(null);
+    expect(await LocalDatastore._getAllContents()).toEqual({});
+  });
+});
+
+describe('LocalDatastore (RNDatastoreController)', () => {
+  beforeEach(async () => {
+    CoreManager.setAsyncStorage(mockAsyncStorage);
+    CoreManager.setLocalDatastoreController(RNDatastoreController);
+    await LocalDatastore._clear();
+  });
+
+  it('can store and retrieve values', async () => {
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual(null);
+    await LocalDatastore.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    expect(await LocalDatastore._getAllContents()).toEqual({ myKey: [{ name: 'test' }] });
+  });
+
+  it('can remove values', async () => {
+    await LocalDatastore.pinWithName('myKey', [{ name: 'test' }]);
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual([{ name: 'test' }]);
+    await LocalDatastore.unPinWithName('myKey');
+    expect(await LocalDatastore.fromPinWithName('myKey')).toEqual(null);
+    expect(await LocalDatastore._getAllContents()).toEqual({});
+  });
+
+  it('can handle store error', async () => {
+    const mockStorageError = {
+      setItem() {
+        throw new Error('error thrown');
+      },
+    };
+    CoreManager.setAsyncStorage(mockStorageError);
+    try {
+      await LocalDatastore.pinWithName('myKey', [{ name: 'test' }]);
+    } catch (e) {
+      expect(e.message).toBe('error thrown');
+    }
+  });
+});
diff --git a/src/__tests__/OfflineQuery-test.js b/src/__tests__/OfflineQuery-test.js
new file mode 100644
index 0000000..486de68
--- /dev/null
+++ b/src/__tests__/OfflineQuery-test.js
@@ -0,0 +1,729 @@
+/**
+ * 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.
+ */
+
+jest.autoMockOff();
+
+const matchesQuery = require('../OfflineQuery').matchesQuery;
+const validateQuery = require('../OfflineQuery').validateQuery;
+const ParseError = require('../ParseError').default;
+const ParseObject = require('../ParseObject').default;
+const ParseQuery = require('../ParseQuery').default;
+const ParseGeoPoint = require('../ParseGeoPoint').default;
+const ParsePolygon = require('../ParsePolygon').default;
+const ParseUser = require('../ParseUser').default;
+
+describe('OfflineQuery', () => {
+
+  it('matches blank queries', () => {
+    const obj = new ParseObject('Item');
+    const q = new ParseQuery('Item');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+    expect(matchesQuery(q.className, obj._toFullJSON(), [], q.toJSON().where)).toBe(true);
+  });
+
+  it('can handle unknown query', () => {
+    const obj = new ParseObject('Item');
+    const q = new ParseQuery('Item');
+    q._addCondition('key', 'unknown', 'value');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+    expect(matchesQuery(q.className, obj._toFullJSON(), [], q.toJSON().where)).toBe(false);
+  });
+
+  it('matches queries null field', () => {
+    const obj = new ParseObject('Item');
+    const q = new ParseQuery('Item');
+    q.equalTo('field', null);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+  });
+
+  it('matches queries invalid key', () => {
+    const obj = new ParseObject('Item');
+    const q = new ParseQuery('Item');
+    q.equalTo('$foo', 'bar');
+    try {
+      matchesQuery(q.className, obj, [], q)
+    } catch (e) {
+      expect(e.message).toBe('Invalid Key: $foo');
+    }
+  });
+
+  it('matches queries date field', () => {
+    const date = new Date();
+    const obj = new ParseObject('Item');
+    obj.set('field', date)
+    const q = new ParseQuery('Item');
+    q.greaterThanOrEqualTo('field', date);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+  });
+
+  it('matches queries relation', () => {
+    const obj = new ParseObject('Item');
+    const relation = obj.relation("author");
+    const q = relation.query();
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+  });
+
+  it('matches existence queries', () => {
+    const obj = new ParseObject('Item');
+    obj.set('count', 100);
+    const q = new ParseQuery('Item');
+    q.exists('count');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+    q.exists('name');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+  });
+
+  it('matches queries with doesNotExist constraint', () => {
+    const obj = new ParseObject('Item');
+    obj.set('count', 100);
+
+    let q = new ParseQuery('Item');
+    q.doesNotExist('name');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+
+    q = new ParseQuery('Item');
+    q.doesNotExist('count');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+  });
+
+  it('matches on equality queries', () => {
+    const day = new Date();
+    const location = new ParseGeoPoint({
+      latitude: 37.484815,
+      longitude: -122.148377,
+    });
+    const obj = new ParseObject('Person');
+    obj
+      .set('score', 12)
+      .set('name', 'Bill')
+      .set('birthday', day)
+      .set('lastLocation', location);
+
+    let q = new ParseQuery('Person');
+    q.equalTo('score', 12);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+
+    q = new ParseQuery('Person');
+    q.equalTo('name', 'Bill');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+    q.equalTo('name', 'Jeff');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+
+    q = new ParseQuery('Person');
+    q.containedIn('name', ['Adam', 'Ben', 'Charles']);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+    q.containedIn('name', ['Adam', 'Bill', 'Charles']);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+
+    q = new ParseQuery('Person');
+    q.notContainedIn('name', ['Adam', 'Bill', 'Charles']);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+    q.notContainedIn('name', ['Adam', 'Ben', 'Charles']);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+
+    q = new ParseQuery('Person');
+    q.equalTo('birthday', day);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+    q.equalTo('birthday', new Date(1990, 1));
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+
+    q = new ParseQuery('Person');
+    q.equalTo('lastLocation', location);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+    q.equalTo('lastLocation', new ParseGeoPoint({
+      latitude: 37.4848,
+      longitude: -122.1483,
+    }));
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+
+    q.equalTo('lastLocation', new ParseGeoPoint({
+      latitude: 37.484815,
+      longitude: -122.148377,
+    }));
+    q.equalTo('score', 12);
+    q.equalTo('name', 'Bill');
+    q.equalTo('birthday', day);
+    expect(matchesQuery(q.className, obj, [], q)).toBe(true);
+
+    q.equalTo('name', 'bill');
+    expect(matchesQuery(q.className, obj, [], q)).toBe(false);
+
+    let img = new ParseObject('Image');
+    img.set('tags', ['nofilter', 'latergram', 'tbt']);
+
+    q = new ParseQuery('Image');
+    q.equalTo('tags', 'selfie');
+    expect(matchesQuery(q.className, img, [], q)).toBe(false);
+    q.equalTo('tags', 'tbt');
+    expect(matchesQuery(q.className, img, [], q)).toBe(true);
+
+    const q2 = new ParseQuery('Image');
+    q2.containsAll('tags', ['latergram', 'nofilter']);
+    expect(matchesQuery(q.className, img, [], q2)).toBe(true);
+    q2.containsAll('tags', ['latergram', 'selfie']);
+    expect(matchesQuery(q.className, img, [], q2)).toBe(false);
+
+    const u = new ParseUser();
+    u.id = 'U2';
+    q = new ParseQuery('Image');
+    q.equalTo('owner', u);
+
+    img = new ParseObject('Image');
+    img.set('owner', u);
+
+    expect(matchesQuery(q.className, img, [], q)).toBe(true);
+
+    let json = img.toJSON();
+    json.owner.objectId = 'U3';
+    expect(matchesQuery(json, q)).toBe(false);
+
+    // pointers in arrays
+    q = new ParseQuery('Image');
+    q.equalTo('owners', u);
+
+    img = new ParseObject('Image');
+    img.set('owners', [u]);
+    expect(matchesQuery(q.className, img, [], q)).toBe(true);
+
+    json = img.toJSON();
+    json.owners[0].objectId = 'U3';
+    expect(matchesQuery(json, q)).toBe(false);
+  });
+
+  it('matches on inequalities', () => {
+    const player = new ParseObject('Person');
+    player
+      .set('score', 12)
+      .set('name', 'Bill')
+      .set('birthday', new Date(1980, 2, 4));
+
+    let q = new ParseQuery('Person');
+    q.lessThan('score', 15);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    q.lessThan('score', 10);
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+
+    q = new ParseQuery('Person');
+    q.lessThanOrEqualTo('score', 15);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    q.lessThanOrEqualTo('score', 12);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    q.lessThanOrEqualTo('score', 10);
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+
+    q = new ParseQuery('Person');
+    q.greaterThan('score', 15);
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+    q.greaterThan('score', 10);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+
+    q = new ParseQuery('Person');
+    q.greaterThanOrEqualTo('score', 15);
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+    q.greaterThanOrEqualTo('score', 12);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    q.greaterThanOrEqualTo('score', 10);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+
+    q = new ParseQuery('Person');
+    q.notEqualTo('score', 12);
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+    q.notEqualTo('score', 40);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+  });
+
+  it('matches an $or query', () => {
+    const player = new ParseObject('Player');
+    player
+      .set('score', 12)
+      .set('name', 'Player 1');
+
+    const q = new ParseQuery('Player');
+    q.equalTo('name', 'Player 1');
+    const q2 = new ParseQuery('Player');
+    q2.equalTo('name', 'Player 2');
+    const q3 = new ParseQuery('Player');
+    q3.equalTo('name', 'Player 3');
+    const orQuery1 = ParseQuery.or(q, q2);
+    const orQuery2 = ParseQuery.or(q2, q3);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    expect(matchesQuery(q.className, player, [], q2)).toBe(false);
+    expect(matchesQuery(q.className, player, [], orQuery1)).toBe(true);
+    expect(matchesQuery(q.className, player, [], orQuery2)).toBe(false);
+  });
+
+  it('matches an $and query', () => {
+    const player = new ParseObject('Player');
+    player
+      .set('score', 12)
+      .set('name', 'Player 1');
+
+    const q = new ParseQuery('Player');
+    q.equalTo('name', 'Player 1');
+    const q2 = new ParseQuery('Player');
+    q2.equalTo('score',  12);
+    const q3 = new ParseQuery('Player');
+    q3.equalTo('score',  100);
+    const andQuery1 = ParseQuery.and(q, q2);
+    const andQuery2 = ParseQuery.and(q, q3);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    expect(matchesQuery(q.className, player, [], q2)).toBe(true);
+    expect(matchesQuery(q.className, player, [], andQuery1)).toBe(true);
+    expect(matchesQuery(q.className, player, [], andQuery2)).toBe(false);
+  });
+
+  it('matches an $nor query', () => {
+    const player = new ParseObject('Player');
+    player
+      .set('score', 12)
+      .set('name', 'Player 1');
+
+    const q = new ParseQuery('Player');
+    q.equalTo('name', 'Player 1');
+    const q2 = new ParseQuery('Player');
+    q2.equalTo('name', 'Player 2');
+    const q3 = new ParseQuery('Player');
+    q3.equalTo('name', 'Player 3');
+
+    const norQuery1 = ParseQuery.nor(q, q2);
+    const norQuery2 = ParseQuery.nor(q2, q3);
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    expect(matchesQuery(q.className, player, [], q2)).toBe(false);
+    expect(matchesQuery(q.className, player, [], q3)).toBe(false);
+    expect(matchesQuery(q.className, player, [], norQuery1)).toBe(false);
+    expect(matchesQuery(q.className, player, [], norQuery2)).toBe(true);
+  });
+
+  it('matches $regex queries', () => {
+    const player = new ParseObject('Player');
+    player
+      .set('score', 12)
+      .set('name', 'Player 1');
+
+    let q = new ParseQuery('Player');
+    q.startsWith('name', 'Play');
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    q.startsWith('name', 'Ploy');
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+
+    q = new ParseQuery('Player');
+    q.endsWith('name', ' 1');
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    q.endsWith('name', ' 2');
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+
+    // Check that special characters are escaped
+    player.set('name', 'Android-7');
+    q = new ParseQuery('Player');
+    q.contains('name', 'd-7');
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+
+    q = new ParseQuery('Player');
+    q.matches('name', /A.d/, 'm');
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+
+    q.matches('name', /A[^n]d/);
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+
+    // Check that the string \\E is returned to normal
+    player.set('name', 'Slash \\E');
+    q = new ParseQuery('Player');
+    q.endsWith('name', 'h \\E');
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+
+    q.endsWith('name', 'h \\Ee');
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+
+    player.set('name', 'Slash \\Q and more');
+    q = new ParseQuery('Player');
+    q.contains('name', 'h \\Q and');
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+    q.contains('name', 'h \\Q or');
+    expect(matchesQuery(q.className, player, [], q)).toBe(false);
+
+    q = new ParseQuery('Player');
+    q._addCondition('status', '$regex', { test: function() { return true } });
+    expect(matchesQuery(q.className, player, [], q)).toBe(true);
+  });
+
+  it('matches $nearSphere queries', () => {
+    let q = new ParseQuery('Checkin');
+    q.near('location', new ParseGeoPoint(20, 20));
+    // With no max distance, any GeoPoint is 'near'
+    const pt = new ParseObject('Checkin');
+    pt.set('location', new ParseGeoPoint(40, 40));
+
+    const ptUndefined = new ParseObject('Checkin');
+
+    const ptNull = new ParseObject('Checkin');
+    ptNull.set('location', null);
+
+    expect(matchesQuery(q.className, pt, [], q)).toBe(true);
+    expect(matchesQuery(q.className, ptUndefined, [], q)).toBe(false);
+    expect(matchesQuery(q.className, ptNull, [], q)).toBe(false);
+
+    q = new ParseQuery('Checkin');
+    pt.set('location', new ParseGeoPoint(40, 40));
+
+    q.withinRadians('location', new ParseGeoPoint(30, 30), 0.3);
+    expect(matchesQuery(q.className, pt, [], q)).toBe(true);
+
+    q.withinRadians('location', new ParseGeoPoint(30, 30), 0.2);
+    expect(matchesQuery(q.className, pt, [], q)).toBe(false);
+
+    q = new ParseQuery('Checkin');
+    q._addCondition('location', '$maxDistance', 100);
+    expect(matchesQuery(q.className, pt, [], q)).toBe(true);
+  });
+
+  it('matches $within queries', () => {
+    const caltrainStation = new ParseObject('Checkin');
+    caltrainStation
+      .set('name', 'Caltrain')
+      .set('location', new ParseGeoPoint(37.776346, -122.394218));
+
+    const santaClara = new ParseObject('Checkin');
+    santaClara
+      .set('name', 'Santa Clara')
+      .set('location', new ParseGeoPoint(37.325635, -121.945753));
+
+    const noLocation = new ParseObject('Checkin');
+    noLocation.set('name', 'Santa Clara');
+
+    const nullLocation = new ParseObject('Checkin');
+    nullLocation
+      .set('name', 'Santa Clara')
+      .set('location', null);
+
+
+    let q = new ParseQuery('Checkin').withinGeoBox(
+      'location',
+      new ParseGeoPoint(37.708813, -122.526398),
+      new ParseGeoPoint(37.822802, -122.373962),
+    );
+
+    expect(matchesQuery(q.className, caltrainStation, [], q)).toBe(true);
+    expect(matchesQuery(q.className, santaClara, [], q)).toBe(false);
+    expect(matchesQuery(q.className, noLocation, [], q)).toBe(false);
+    expect(matchesQuery(q.className, nullLocation, [], q)).toBe(false);
+    // Invalid rectangles
+    q = new ParseQuery('Checkin').withinGeoBox(
+      'location',
+      new ParseGeoPoint(37.822802, -122.373962),
+      new ParseGeoPoint(37.708813, -122.526398),
+    );
+
+    expect(matchesQuery(q.className, caltrainStation, [], q)).toBe(false);
+    expect(matchesQuery(q.className, santaClara, [], q)).toBe(false);
+
+    q = new ParseQuery('Checkin').withinGeoBox(
+      'location',
+      new ParseGeoPoint(37.708813, -122.373962),
+      new ParseGeoPoint(37.822802, -122.526398),
+    );
+
+    expect(matchesQuery(q.className, caltrainStation, [], q)).toBe(false);
+    expect(matchesQuery(q.className, santaClara, [], q)).toBe(false);
+  });
+
+  it('matches on subobjects with dot notation', () => {
+    const message = new ParseObject('Message');
+    message
+      .set('test', 'content')
+      .set('status', { x: 'read', y: 'delivered' });
+
+    let q = new ParseQuery('Message');
+    q.equalTo('status.x', 'read');
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    q = new ParseQuery('Message');
+    q.equalTo('status.z', 'read');
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+
+    q = new ParseQuery('Message');
+    q.equalTo('status.x', 'delivered');
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+
+    q = new ParseQuery('Message');
+    q.notEqualTo('status.x', 'read');
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+
+    q = new ParseQuery('Message');
+    q.notEqualTo('status.z', 'read');
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    q = new ParseQuery('Message');
+    q.notEqualTo('status.x', 'delivered');
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    q = new ParseQuery('Message');
+    q.exists('status.x');
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    q = new ParseQuery('Message');
+    q.exists('status.z');
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+
+    q = new ParseQuery('Message');
+    q.exists('nonexistent.x');
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+
+    q = new ParseQuery('Message');
+    q.doesNotExist('status.x');
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+
+    q = new ParseQuery('Message');
+    q.doesNotExist('status.z');
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    q = new ParseQuery('Message');
+    q.doesNotExist('nonexistent.z');
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    q = new ParseQuery('Message');
+    q.equalTo('status.x', 'read');
+    q.doesNotExist('status.y');
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+
+    q = new ParseQuery('Message');
+    q._addCondition('status', '$exists', 'invalid');
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+  });
+
+  it('should support containedIn with pointers', () => {
+    const profile = new ParseObject('Profile');
+    profile.id = 'abc';
+    const message = new ParseObject('Message');
+    message.set('profile', profile);
+
+    let q = new ParseQuery('Message');
+    q.containedIn('profile', [ParseObject.fromJSON({ className: 'Profile', objectId: 'abc' }),
+      ParseObject.fromJSON({ className: 'Profile', objectId: 'def' })]);
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    q = new ParseQuery('Message');
+    q.containedIn('profile', [ParseObject.fromJSON({ className: 'Profile', objectId: 'ghi' }),
+      ParseObject.fromJSON({ className: 'Profile', objectId: 'def' })]);
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+  });
+
+  it('should support notContainedIn with pointers', () => {
+    const profile = new ParseObject('Profile');
+    profile.id = 'abc';
+    let message = new ParseObject('Message');
+    message.id = 'O1';
+    message.set('profile', profile);
+
+    let q = new ParseQuery('Message');
+    q.notContainedIn('profile', [ParseObject.fromJSON({ className: 'Profile', objectId: 'def' }),
+      ParseObject.fromJSON({ className: 'Profile', objectId: 'ghi' })]);
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    profile.id = 'def';
+    message = new ParseObject('Message');
+    message.set('profile', profile);
+    q = new ParseQuery('Message');
+    q.notContainedIn('profile', [ParseObject.fromJSON({ className: 'Profile', objectId: 'ghi' }),
+      ParseObject.fromJSON({ className: 'Profile', objectId: 'def' })]);
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+  });
+
+  it('should support containedIn queries with [objectId]', () => {
+    const profile = new ParseObject('Profile');
+    profile.id = 'abc';
+    let message = new ParseObject('Message');
+    message.set('profile', profile);
+
+    let q = new ParseQuery('Message');
+    q.containedIn('profile', ['abc', 'def']);
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    profile.id = 'ghi';
+    message = new ParseObject('Message');
+    message.set('profile', profile);
+
+    q = new ParseQuery('Message');
+    q.containedIn('profile', ['abc', 'def']);
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+  });
+
+  it('should support notContainedIn queries with [objectId]', () => {
+    const profile = new ParseObject('Profile');
+    profile.id = 'ghi';
+    const message = new ParseObject('Message');
+    message.set('profile', profile);
+
+    let q = new ParseQuery('Message');
+    q.notContainedIn('profile', ['abc', 'def']);
+    expect(matchesQuery(q.className, message, [], q)).toBe(true);
+
+    q = new ParseQuery('Message');
+    q.notContainedIn('profile', ['abc', 'def', 'ghi']);
+    expect(matchesQuery(q.className, message, [], q)).toBe(false);
+  });
+
+  it('should support matchesKeyInQuery', () => {
+    const restaurant = new ParseObject('Restaurant');
+    restaurant.set('ratings', 5);
+    restaurant.set('location', 'Earth');
+    const person1 = new ParseObject('Person');
+    person1.set('hometown', 'Earth');
+    const person2 = new ParseObject('Person');
+    person2.set('hometown', 'Mars');
+
+    let query = new ParseQuery('Restaurant');
+    query.greaterThan('rating', 4);
+    let mainQuery = new ParseQuery('Person');
+
+    mainQuery.matchesKeyInQuery('hometown', 'location', query);
+    expect(matchesQuery(mainQuery.className, person1, [person1, person2, restaurant], mainQuery)).toBe(true);
+    expect(matchesQuery(mainQuery.className, person2, [person1, person2, restaurant], mainQuery)).toBe(false);
+    expect(matchesQuery(mainQuery.className, person1, [], mainQuery)).toBe(false);
+
+    query = new ParseQuery('Restaurant');
+    query.greaterThan('rating', 4);
+    mainQuery = new ParseQuery('Person');
+
+    mainQuery.doesNotMatchKeyInQuery('hometown', 'location', query);
+    expect(matchesQuery(mainQuery.className, person1, [person1, person2, restaurant._toFullJSON()], mainQuery)).toBe(false);
+    expect(matchesQuery(mainQuery.className, person2, [person1, person2, restaurant], mainQuery)).toBe(true);
+    expect(matchesQuery(mainQuery.className, person1, [], mainQuery)).toBe(false);
+  });
+
+  it('should support matchesQuery', () => {
+    const parentObjects = [];
+    const childObjects = [];
+    for (let i = 0; i < 10; i += 1) {
+      const child = new ParseObject('ChildObject');
+      child.id = 100 + i;
+      child.set('x', i);
+      const parent = new ParseObject('ParentObject');
+      parent.id = 10 + i;
+      parent.set('child', child);
+      childObjects.push(child);
+      parentObjects.push(parent);
+    }
+    let subQuery = new ParseQuery('ChildObject');
+    subQuery.greaterThan('x', 5);
+    let q = new ParseQuery('ParentObject');
+    q.matchesQuery('child', subQuery);
+    expect(matchesQuery(q.className, parentObjects[0], [...parentObjects, ...childObjects], q)).toBe(false);
+    expect(matchesQuery(q.className, parentObjects[9], [...parentObjects, ...childObjects], q)).toBe(true);
+    expect(matchesQuery(q.className, parentObjects[0], [], q)).toBe(false);
+
+    subQuery = new ParseQuery('ChildObject');
+    subQuery.greaterThan('x', 5);
+    q = new ParseQuery('ParentObject');
+    q.doesNotMatchQuery('child', subQuery);
+    expect(matchesQuery(q.className, parentObjects[0], [...parentObjects, ...childObjects], q)).toBe(true);
+    expect(matchesQuery(q.className, parentObjects[9], [...parentObjects, ...childObjects], q)).toBe(false);
+    expect(matchesQuery(q.className, parentObjects[0], [], q)).toBe(true);
+  });
+
+  it('should support containedBy query', () => {
+    const obj1 = new ParseObject('Numbers');
+    const obj2 = new ParseObject('Numbers');
+    const obj3 = new ParseObject('Numbers');
+    obj1.set('numbers', [0, 1, 2]);
+    obj2.set('numbers', [2, 0]);
+    obj3.set('numbers', [1, 2, 3, 4]);
+
+    const q = new ParseQuery('Numbers');
+    q.containedBy('numbers', [1, 2, 3, 4, 5]);
+    expect(matchesQuery(q.className, obj1, [], q)).toBe(false);
+    expect(matchesQuery(q.className, obj2, [], q)).toBe(false);
+    expect(matchesQuery(q.className, obj3, [], q)).toBe(true);
+  });
+
+  it('should support withinPolygon query', () => {
+    const sacramento = new ParseObject('Location');
+    sacramento.set('location', new ParseGeoPoint(38.52, -121.50));
+    sacramento.set('name', 'Sacramento');
+
+    const honolulu = new ParseObject('Location');
+    honolulu.set('location', new ParseGeoPoint(21.35, -157.93));
+    honolulu.set('name', 'Honolulu');
+
+    const sf = new ParseObject('Location');
+    sf.set('location', new ParseGeoPoint(37.75, -122.68));
+    sf.set('name', 'San Francisco')
+
+    const points = [
+      new ParseGeoPoint(37.85, -122.33),
+      new ParseGeoPoint(37.85, -122.90),
+      new ParseGeoPoint(37.68, -122.90),
+      new ParseGeoPoint(37.68, -122.33)
+    ];
+    const q = new ParseQuery('Location');
+    q.withinPolygon('location', points);
+
+    expect(matchesQuery(q.className, sacramento, [], q)).toBe(false);
+    expect(matchesQuery(q.className, honolulu, [], q)).toBe(false);
+    expect(matchesQuery(q.className, sf, [], q)).toBe(true);
+  });
+
+  it('should support polygonContains query', () => {
+    const p1 = [[0,0], [0,1], [1,1], [1,0]];
+    const p2 = [[0,0], [0,2], [2,2], [2,0]];
+    const p3 = [[10,10], [10,15], [15,15], [15,10], [10,10]];
+
+    const polygon1 = new ParsePolygon(p1);
+    const polygon2 = new ParsePolygon(p2);
+    const polygon3 = new ParsePolygon(p3);
+    const obj1 = new ParseObject('Bounds');
+    const obj2 = new ParseObject('Bounds');
+    const obj3 = new ParseObject('Bounds');
+    obj1.set('polygon', polygon1);
+    obj2.set('polygon', polygon2);
+    obj3.set('polygon', polygon3);
+
+    const point = new ParseGeoPoint(0.5, 0.5);
+    const q = new ParseQuery('Bounds');
+    q.polygonContains('polygon', point);
+
+    expect(matchesQuery(q.className, obj1, [], q)).toBe(true);
+    expect(matchesQuery(q.className, obj2, [], q)).toBe(true);
+    expect(matchesQuery(q.className, obj3, [], q)).toBe(false);
+  });
+
+  it('should validate query', () => {
+    let query = new ParseQuery('TestObject');
+    query.equalTo('foo', 'bar');
+    try {
+      validateQuery(query);
+      validateQuery(query.toJSON());
+
+      query.matches('myString', 'football', 'm');
+      validateQuery(query);
+
+      expect(true).toBe(true);
+    } catch (e) {
+      // Should not reach here
+      expect(false).toEqual(true);
+    }
+
+    query = new ParseQuery('TestObject');
+    query.matches('myString', 'football', 'some invalid thing');
+    try {
+      validateQuery(query);
+      expect(true).toBe(false);
+    } catch (e) {
+      expect(e.code).toEqual(ParseError.INVALID_QUERY);
+    }
+
+    query = new ParseQuery('TestObject');
+    query.equalTo('$foo', 'bar');
+    try {
+      validateQuery(query);
+      expect(true).toBe(false);
+    } catch (e) {
+      expect(e.code).toEqual(ParseError.INVALID_KEY_NAME);
+    }
+  });
+});
diff --git a/src/__tests__/Parse-test.js b/src/__tests__/Parse-test.js
index d089ef7..a4052f6 100644
--- a/src/__tests__/Parse-test.js
+++ b/src/__tests__/Parse-test.js
@@ -9,6 +9,7 @@
 
 jest.dontMock('../CoreManager');
 jest.dontMock('../Parse');
+jest.dontMock('../LocalDatastore');
 
 const CoreManager = require('../CoreManager');
 const Parse = require('../Parse');
@@ -44,4 +45,57 @@ describe('Parse module', () => {
     expect(CoreManager.get('MASTER_KEY')).toBe('789');
     expect(Parse.masterKey).toBe('789');
   });
+
+  it('can set LocalDatastoreController', () => {
+    const controller = {
+      fromPinWithName: function() {},
+      pinWithName: function() {},
+      unPinWithName: function() {},
+      getAllContents: function() {},
+      clear: function() {}
+    };
+    Parse.setLocalDatastoreController(controller);
+    expect(CoreManager.getLocalDatastoreController()).toBe(controller);
+  });
+
+  it('can set AsyncStorage', () => {
+    const controller = {
+      getItem: function() {},
+      setItem: function() {},
+      removeItem: function() {},
+      getItemAsync: function() {},
+      setItemAsync: function() {},
+      removeItemAsync: function() {},
+      clear: function() {},
+    };
+
+    Parse.setAsyncStorage(controller);
+    expect(CoreManager.getAsyncStorage()).toBe(controller);
+  });
+
+  it('can enable LocalDatastore', () => {
+    Parse.LocalDatastore.isEnabled = false;
+    Parse.enableLocalDatastore();
+    expect(Parse.LocalDatastore.isEnabled).toBe(true);
+    expect(Parse.isLocalDatastoreEnabled()).toBe(true);
+  });
+
+  it('can dump LocalDatastore', async () => {
+    Parse.LocalDatastore.isEnabled = false;
+    let LDS = await Parse.dumpLocalDatastore();
+    expect(LDS).toEqual({});
+    Parse.LocalDatastore.isEnabled = true;
+    const controller = {
+      fromPinWithName: function() {},
+      pinWithName: function() {},
+      unPinWithName: function() {},
+      getAllContents: function() {
+        return Promise.resolve({ key: 'value' });
+      },
+      clear: function() {}
+    };
+    Parse.setLocalDatastoreController(controller);
+    LDS = await Parse.dumpLocalDatastore();
+    expect(LDS).toEqual({ key: 'value' });
+  });
 });
diff --git a/src/__tests__/ParseObject-test.js b/src/__tests__/ParseObject-test.js
index b937b55..8b02401 100644
--- a/src/__tests__/ParseObject-test.js
+++ b/src/__tests__/ParseObject-test.js
@@ -30,6 +30,7 @@ jest.dontMock('../unique');
 jest.dontMock('../UniqueInstanceStateController');
 jest.dontMock('../unsavedChildren');
 jest.dontMock('../ParseACL');
+jest.dontMock('../LocalDatastore');
 
 jest.dontMock('./test_helpers/mockXHR');
 
@@ -102,6 +103,33 @@ mockQuery.prototype.find = function() {
 };
 jest.setMock('../ParseQuery', mockQuery);
 
+const mockLocalDatastore = {
+  DEFAULT_PIN: '_default',
+  PIN_PREFIX: 'parsePin_',
+  isEnabled: false,
+  fromPinWithName: jest.fn(),
+  pinWithName: jest.fn(),
+  unPinWithName: jest.fn(),
+  _handlePinWithName: jest.fn(),
+  _handleUnPinWithName: jest.fn(),
+  _getAllContent: jest.fn(),
+  _serializeObjectsFromPinName: jest.fn(),
+  _serializeObject: jest.fn(),
+  _transverseSerializeObject: jest.fn(),
+  _updateObjectIfPinned: jest.fn(),
+  _destroyObjectIfPinned: jest.fn(),
+  _updateLocalIdForObject: jest.fn(),
+  _clear: jest.fn(),
+  getKeyForObject: jest.fn(),
+  checkIfEnabled: jest.fn(() => {
+    if (!mockLocalDatastore.isEnabled) {
+      console.log('Parse.enableLocalDatastore() must be called first'); // eslint-disable-line no-console
+    }
+    return mockLocalDatastore.isEnabled;
+  }),
+};
+jest.setMock('../LocalDatastore', mockLocalDatastore);
+
 const CoreManager = require('../CoreManager');
 const ParseACL = require('../ParseACL').default;
 const ParseError = require('../ParseError').default;
@@ -112,9 +140,11 @@ const ParseOp = require('../ParseOp');
 const RESTController = require('../RESTController');
 const SingleInstanceStateController = require('../SingleInstanceStateController');
 const unsavedChildren = require('../unsavedChildren').default;
+const LocalDatastore = require('../LocalDatastore');
 
 const mockXHR = require('./test_helpers/mockXHR');
 
+CoreManager.setLocalDatastore(mockLocalDatastore);
 CoreManager.setRESTController(RESTController);
 CoreManager.setInstallationController({
   currentInstallationId() {
@@ -2530,3 +2560,120 @@ describe('ParseObject extensions', () => {
     expect(i.get('field')).toBe(12);
   });
 });
+
+describe('ParseObject pin', () => {
+  beforeEach(() => {
+    ParseObject.enableSingleInstance();
+    jest.clearAllMocks();
+    mockLocalDatastore.isEnabled = true;
+  });
+
+  it('can pin to default', async () => {
+    const object = new ParseObject('Item');
+    await object.pin();
+    expect(mockLocalDatastore._handlePinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalDatastore._handlePinWithName).toHaveBeenCalledWith(LocalDatastore.DEFAULT_PIN, object);
+  });
+
+  it('can unPin to default', async () => {
+    const object = new ParseObject('Item');
+    await object.unPin();
+    expect(mockLocalDatastore._handleUnPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalDatastore._handleUnPinWithName).toHaveBeenCalledWith(LocalDatastore.DEFAULT_PIN, object);
+  });
+
+  it('can pin to specific pin', async () => {
+    const object = new ParseObject('Item');
+    await object.pinWithName('test_pin');
+    expect(mockLocalDatastore._handlePinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalDatastore._handlePinWithName).toHaveBeenCalledWith('test_pin', object);
+  });
+
+  it('can unPin to specific', async () => {
+    const object = new ParseObject('Item');
+    await object.unPinWithName('test_pin');
+    expect(mockLocalDatastore._handleUnPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalDatastore._handleUnPinWithName).toHaveBeenCalledWith('test_pin', object);
+  });
+
+  it('can fetchFromLocalDatastore', async () => {
+    const object = new ParseObject('Item');
+    object.id = '123';
+    mockLocalDatastore
+      .getKeyForObject
+      .mockImplementationOnce(() => 'Item_123');
+
+    mockLocalDatastore
+      ._serializeObject
+      .mockImplementationOnce(() => object._toFullJSON());
+
+    await object.fetchFromLocalDatastore();
+    expect(mockLocalDatastore._serializeObject).toHaveBeenCalledTimes(1);
+    expect(mockLocalDatastore._serializeObject).toHaveBeenCalledWith('Item_123');
+  });
+
+  it('cannot fetchFromLocalDatastore if unsaved', async () => {
+    try {
+      const object = new ParseObject('Item');
+      await object.fetchFromLocalDatastore();
+    } catch (e) {
+      expect(e.message).toBe('Cannot fetch an unsaved ParseObject');
+    }
+  });
+
+  it('can pinAll', async () => {
+    const obj1 = new ParseObject('Item');
+    const obj2 = new ParseObject('Item');
+    await ParseObject.pinAll([obj1, obj2]);
+    expect(mockLocalDatastore._handlePinWithName).toHaveBeenCalledTimes(2);
+    expect(mockLocalDatastore._handlePinWithName.mock.calls[0]).toEqual([LocalDatastore.DEFAULT_PIN, obj1]);
+    expect(mockLocalDatastore._handlePinWithName.mock.calls[1]).toEqual([LocalDatastore.DEFAULT_PIN, obj2]);
+  });
+
+  it('can unPinAll', async () => {
+    const obj1 = new ParseObject('Item');
+    const obj2 = new ParseObject('Item');
+    await ParseObject.unPinAll([obj1, obj2]);
+    expect(mockLocalDatastore._handleUnPinWithName).toHaveBeenCalledTimes(2);
+    expect(mockLocalDatastore._handleUnPinWithName.mock.calls[0]).toEqual([LocalDatastore.DEFAULT_PIN, obj1]);
+    expect(mockLocalDatastore._handleUnPinWithName.mock.calls[1]).toEqual([LocalDatastore.DEFAULT_PIN, obj2]);
+  });
+
+  it('can unPinAllObjects', async () => {
+    await ParseObject.unPinAllObjects();
+    expect(mockLocalDatastore.unPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalDatastore.unPinWithName.mock.calls[0]).toEqual([LocalDatastore.DEFAULT_PIN]);
+  });
+
+  it('can unPinAllObjectsWithName', async () => {
+    await ParseObject.unPinAllObjectsWithName('123');
+    expect(mockLocalDatastore.unPinWithName).toHaveBeenCalledTimes(1);
+    expect(mockLocalDatastore.unPinWithName.mock.calls[0]).toEqual([LocalDatastore.PIN_PREFIX + '123']);
+  });
+
+  it('cannot pin when localDatastore disabled', async () => {
+    mockLocalDatastore.isEnabled = false;
+    const spy = jest.spyOn(
+      console,
+      'log'
+    );
+    const name = 'test_pin';
+    const obj = new ParseObject('Item');
+    await obj.pin();
+    await obj.unPin();
+    await obj.pinWithName(name);
+    await obj.unPinWithName(name);
+    await obj.fetchFromLocalDatastore();
+
+    await ParseObject.pinAll([obj]);
+    await ParseObject.unPinAll([obj]);
+    await ParseObject.pinAllWithName(name, [obj]);
+    await ParseObject.unPinAllWithName(name, [obj]);
+    await ParseObject.unPinAllObjects();
+    await ParseObject.unPinAllObjectsWithName(name);
+
+    expect(spy).toHaveBeenCalledTimes(11);
+    expect(spy).toHaveBeenCalledWith('Parse.enableLocalDatastore() must be called first');
+    spy.mockRestore();
+  });
+});
diff --git a/src/__tests__/ParseQuery-test.js b/src/__tests__/ParseQuery-test.js
index c894f2a..347bd87 100644
--- a/src/__tests__/ParseQuery-test.js
+++ b/src/__tests__/ParseQuery-test.js
@@ -17,6 +17,8 @@ jest.dontMock('../promiseUtils');
 jest.dontMock('../SingleInstanceStateController');
 jest.dontMock('../UniqueInstanceStateController');
 jest.dontMock('../ObjectStateMutations');
+jest.dontMock('../LocalDatastore');
+jest.dontMock('../OfflineQuery');
 
 const mockObject = function(className) {
   this.className = className;
@@ -35,7 +37,14 @@ mockObject.fromJSON = function(json) {
 };
 jest.setMock('../ParseObject', mockObject);
 
+const mockLocalDatastore = {
+  _serializeObjectsFromPinName: jest.fn(),
+  checkIfEnabled: jest.fn(),
+};
+jest.setMock('../LocalDatastore', mockLocalDatastore);
+
 let CoreManager = require('../CoreManager');
+const LocalDatastore = require('../LocalDatastore');
 const ParseError = require('../ParseError').default;
 const ParseGeoPoint = require('../ParseGeoPoint').default;
 let ParseObject = require('../ParseObject');
@@ -2149,4 +2158,473 @@ describe('ParseQuery', () => {
       order: '$score',
     });
   });
+
+});
+
+describe('ParseQuery LocalDatastore', () => {
+  beforeEach(() => {
+    CoreManager.setLocalDatastore(mockLocalDatastore);
+    jest.clearAllMocks();
+  });
+
+  it('can query from local datastore', () => {
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementationOnce(() => true);
+
+    const q = new ParseQuery('Item');
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+    q.fromLocalDatastore();
+    expect(q._queriesLocalDatastore).toBe(true);
+    expect(q._localDatastorePinName).toBe(null);
+  });
+
+  it('can query from default pin', () => {
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementationOnce(() => true);
+
+    const q = new ParseQuery('Item');
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+    q.fromPin();
+    expect(q._queriesLocalDatastore).toBe(true);
+    expect(q._localDatastorePinName).toBe(LocalDatastore.DEFAULT_PIN);
+  });
+
+  it('can query from pin with name', () => {
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementationOnce(() => true);
+
+    const q = new ParseQuery('Item');
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+    q.fromPinWithName('test_pin');
+    expect(q._queriesLocalDatastore).toBe(true);
+    expect(q._localDatastorePinName).toBe('test_pin');
+  });
+
+  it('cannot query from local datastore if disabled', () => {
+    const q = new ParseQuery('Item');
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+    q.fromLocalDatastore();
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+  });
+
+  it('can query from default pin if disabled', () => {
+    const q = new ParseQuery('Item');
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+    q.fromPin();
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+  });
+
+  it('can query from pin with name if disabled', () => {
+    const q = new ParseQuery('Item');
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+    q.fromPinWithName('test_pin');
+    expect(q._queriesLocalDatastore).toBe(false);
+    expect(q._localDatastorePinName).toBe(null);
+  });
+
+  it('can query offline', async () => {
+    const obj1 = {
+      className: 'Item',
+      objectId: 'objectId1',
+      count: 2,
+    };
+
+    const obj2 = {
+      className: 'Item',
+      objectId: 'objectId2',
+    };
+
+    const obj3 = {
+      className: 'Unknown',
+      objectId: 'objectId3',
+    };
+
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementationOnce(() => [obj1, obj2, obj3]);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementationOnce(() => true);
+
+    const q = new ParseQuery('Item');
+    q.equalTo('count', 2);
+    q.fromLocalDatastore();
+    const results = await q.find();
+    expect(results[0].id).toEqual(obj1.objectId);
+  });
+
+  it('can query offline first', async () => {
+    const obj1 = {
+      className: 'Item',
+      objectId: 'objectId1',
+      count: 2,
+    };
+
+    const obj2 = {
+      className: 'Item',
+      objectId: 'objectId2',
+    };
+
+    const obj3 = {
+      className: 'Unknown',
+      objectId: 'objectId3',
+    };
+
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementationOnce(() => [obj1, obj2, obj3]);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementationOnce(() => true);
+
+    let q = new ParseQuery('Item');
+    q.fromLocalDatastore();
+    let result = await q.first();
+    expect(result.id).toEqual(obj1.objectId);
+
+    jest.clearAllMocks();
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementationOnce(() => []);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementationOnce(() => true);
+
+    q = new ParseQuery('Item');
+    q.fromLocalDatastore();
+    result = await q.first();
+    expect(result).toEqual(undefined);
+  });
+
+  it('can query offline sort', async () => {
+    const obj1 = {
+      className: 'Item',
+      objectId: 'objectId1',
+      password: 123,
+      number: 2,
+      createdAt: new Date('2018-08-10T00:00:00.000Z'),
+      updatedAt: new Date('2018-08-10T00:00:00.000Z'),
+    };
+
+    const obj2 = {
+      className: 'Item',
+      objectId: 'objectId2',
+      password: 123,
+      number: 3,
+      createdAt: new Date('2018-08-11T00:00:00.000Z'),
+      updatedAt: new Date('2018-08-11T00:00:00.000Z'),
+    };
+
+    const obj3 = {
+      className: 'Item',
+      objectId: 'objectId3',
+      password: 123,
+      number: 4,
+      createdAt: new Date('2018-08-12T00:00:00.000Z'),
+      updatedAt: new Date('2018-08-12T00:00:00.000Z'),
+    };
+
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementation(() => [obj1, obj2, obj3]);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementation(() => true);
+
+    let q = new ParseQuery('Item');
+    q.ascending('number');
+    q.fromLocalDatastore();
+    let results = await q.find();
+    expect(results[0].get('number')).toEqual(2);
+    expect(results[1].get('number')).toEqual(3);
+    expect(results[2].get('number')).toEqual(4);
+
+    q = new ParseQuery('Item');
+    q.descending('number');
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results[0].get('number')).toEqual(4);
+    expect(results[1].get('number')).toEqual(3);
+    expect(results[2].get('number')).toEqual(2);
+
+    q = new ParseQuery('Item');
+    q.descending('number');
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results[0].get('number')).toEqual(4);
+    expect(results[1].get('number')).toEqual(3);
+    expect(results[2].get('number')).toEqual(2);
+
+    q = new ParseQuery('Item');
+    q.descending('password');
+    q.fromLocalDatastore();
+    try {
+      results = await q.find();
+    } catch (e) {
+      expect(e.message).toEqual('Invalid Key: password');
+    }
+  });
+
+  it('can query offline sort multiple', async () => {
+    const obj1 = {
+      className: 'Item',
+      objectId: 'objectId1',
+      password: 123,
+      number: 3,
+      string: 'a',
+    };
+
+    const obj2 = {
+      className: 'Item',
+      objectId: 'objectId2',
+      number: 1,
+      string: 'b',
+    };
+
+    const obj3 = {
+      className: 'Item',
+      objectId: 'objectId3',
+      number: 3,
+      string: 'c',
+    };
+
+    const obj4 = {
+      className: 'Item',
+      objectId: 'objectId4',
+      number: 2,
+      string: 'd',
+    };
+
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementation(() => [obj1, obj2, obj3, obj4]);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementation(() => true);
+
+    let q = new ParseQuery('Item');
+    q.ascending('number,string');
+    q.fromLocalDatastore();
+    let results = await q.find();
+    expect(results[0].get('number')).toEqual(1);
+    expect(results[1].get('number')).toEqual(2);
+    expect(results[2].get('number')).toEqual(3);
+    expect(results[3].get('number')).toEqual(3);
+    expect(results[0].get('string')).toEqual('b');
+    expect(results[1].get('string')).toEqual('d');
+    expect(results[2].get('string')).toEqual('a');
+    expect(results[3].get('string')).toEqual('c');
+
+    q = new ParseQuery('Item');
+    q.ascending('number').addDescending('string');
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results[0].get('number')).toEqual(1);
+    expect(results[1].get('number')).toEqual(2);
+    expect(results[2].get('number')).toEqual(3);
+    expect(results[3].get('number')).toEqual(3);
+    expect(results[0].get('string')).toEqual('b');
+    expect(results[1].get('string')).toEqual('d');
+    expect(results[2].get('string')).toEqual('c');
+    expect(results[3].get('string')).toEqual('a');
+
+    q = new ParseQuery('Item');
+    q.descending('number,string');
+    q.fromLocalDatastore();
+    results = await q.find();
+
+    expect(results[0].get('number')).toEqual(3);
+    expect(results[1].get('number')).toEqual(3);
+    expect(results[2].get('number')).toEqual(2);
+    expect(results[3].get('number')).toEqual(1);
+    expect(results[0].get('string')).toEqual('c');
+    expect(results[1].get('string')).toEqual('a');
+    expect(results[2].get('string')).toEqual('d');
+    expect(results[3].get('string')).toEqual('b');
+
+    q = new ParseQuery('Item');
+    q.descending('number').addAscending('string');
+    q.fromLocalDatastore();
+    results = await q.find();
+
+    expect(results[0].get('number')).toEqual(3);
+    expect(results[1].get('number')).toEqual(3);
+    expect(results[2].get('number')).toEqual(2);
+    expect(results[3].get('number')).toEqual(1);
+    expect(results[0].get('string')).toEqual('a');
+    expect(results[1].get('string')).toEqual('c');
+    expect(results[2].get('string')).toEqual('d');
+    expect(results[3].get('string')).toEqual('b');
+  });
+
+  it('can query offline limit', async () => {
+    const obj1 = {
+      className: 'Item',
+      objectId: 'objectId1',
+      number: 3,
+      string: 'a',
+    };
+
+    const obj2 = {
+      className: 'Item',
+      objectId: 'objectId2',
+      number: 1,
+      string: 'b',
+    };
+
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementation(() => [obj1, obj2]);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementation(() => true);
+
+    let q = new ParseQuery('Item');
+    q.limit(0);
+    q.fromLocalDatastore();
+    let results = await q.find();
+    expect(results.length).toEqual(2);
+
+    q = new ParseQuery('Item');
+    q.limit(1);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(1);
+
+    q = new ParseQuery('Item');
+    q.limit(2);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(2);
+
+    q = new ParseQuery('Item');
+    q.limit(3);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(2);
+
+    q = new ParseQuery('Item');
+    q.limit(-1);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(2);
+  });
+
+  it('can query offline skip', async () => {
+    const obj1 = {
+      className: 'Item',
+      objectId: 'objectId1',
+      password: 123,
+      number: 3,
+      string: 'a',
+    };
+
+    const obj2 = {
+      className: 'Item',
+      objectId: 'objectId2',
+      number: 1,
+      string: 'b',
+    };
+
+    const obj3 = {
+      className: 'Item',
+      objectId: 'objectId3',
+      number: 2,
+      string: 'c',
+    };
+
+    const objects = [obj1, obj2, obj3];
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementation(() => objects);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementation(() => true);
+
+    let q = new ParseQuery('Item');
+    q.skip(0);
+    q.fromLocalDatastore();
+    let results = await q.find();
+    expect(results.length).toEqual(3);
+
+    q = new ParseQuery('Item');
+    q.skip(1);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(2);
+
+    q = new ParseQuery('Item');
+    q.skip(3);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(0);
+
+    q = new ParseQuery('Item');
+    q.skip(4);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(0);
+
+    q = new ParseQuery('Item');
+    q.limit(1);
+    q.skip(2);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(1);
+
+    q = new ParseQuery('Item');
+    q.limit(1);
+    q.skip(1);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(1);
+
+    q = new ParseQuery('Item');
+    q.limit(2);
+    q.skip(1);
+    q.fromLocalDatastore();
+    results = await q.find();
+    expect(results.length).toEqual(2);
+  });
+
+  it('can query offline select keys', async () => {
+    const obj1 = {
+      className: 'Item',
+      objectId: 'objectId1',
+      foo: 'baz',
+      bar: 1,
+    };
+
+    mockLocalDatastore
+      ._serializeObjectsFromPinName
+      .mockImplementation(() => [obj1]);
+
+    mockLocalDatastore
+      .checkIfEnabled
+      .mockImplementation(() => true);
+
+    const q = new ParseQuery('Item');
+    q.select('foo');
+    q.fromLocalDatastore();
+    const results = await q.find();
+    expect(results[0].get('foo')).toEqual('baz');
+  });
 });
diff --git a/src/__tests__/ParseUser-test.js b/src/__tests__/ParseUser-test.js
index 112c8cf..30886c5 100644
--- a/src/__tests__/ParseUser-test.js
+++ b/src/__tests__/ParseUser-test.js
@@ -11,7 +11,8 @@ jest.dontMock('../CoreManager');
 jest.dontMock('../decode');
 jest.dontMock('../encode');
 jest.dontMock('../isRevocableSession');
-jest.dontMock('../ObjectStateMutations')
+jest.dontMock('../LocalDatastore');
+jest.dontMock('../ObjectStateMutations');
 jest.dontMock('../parseDate');
 jest.dontMock('../ParseError');
 jest.dontMock('../ParseObject');
@@ -28,6 +29,7 @@ jest.dontMock('../UniqueInstanceStateController');
 jest.dontMock('./test_helpers/mockXHR');
 
 const CoreManager = require('../CoreManager');
+const LocalDatastore = require('../LocalDatastore');
 const ParseObject = require('../ParseObject').default;
 const ParseUser = require('../ParseUser').default;
 const Storage = require('../Storage');
@@ -39,6 +41,7 @@ CoreManager.set('JAVASCRIPT_KEY', 'B');
 describe('ParseUser', () => {
   beforeEach(() => {
     ParseObject.enableSingleInstance();
+    LocalDatastore._clear();
   });
 
   it('can be constructed with initial attributes', () => {
diff --git a/src/__tests__/Storage-test.js b/src/__tests__/Storage-test.js
index d1e7932..aaf7b61 100644
--- a/src/__tests__/Storage-test.js
+++ b/src/__tests__/Storage-test.js
@@ -8,27 +8,8 @@
  */
 
 jest.autoMockOff();
-let mockRNStorage = {};
-const mockRNStorageInterface = {
-  getItem(path, cb) {
-    cb(undefined, mockRNStorage[path] || null);
-  },
-
-  setItem(path, value, cb) {
-    mockRNStorage[path] = value;
-    cb();
-  },
-
-  removeItem(path, cb) {
-    delete mockRNStorage[path];
-    cb();
-  },
-
-  clear() {
-    mockRNStorage = {};
-  },
-};
 
+const mockRNStorageInterface = require('./test_helpers/mockRNStorage');
 const CoreManager = require('../CoreManager');
 
 let mockStorage = {};
@@ -132,6 +113,70 @@ describe('React Native StorageController', () => {
       done();
     });
   });
+
+  it('can getAllKeys', (done) => {
+    RNStorageController.setItemAsync('myKey', 'myValue').then(() => {
+      return RNStorageController.getItemAsync('myKey');
+    }).then((result) => {
+      expect(result).toBe('myValue');
+      return RNStorageController.getAllKeys();
+    }).then((keys) => {
+      expect(keys[0]).toBe('myKey');
+      done();
+    });
+  });
+
+  it('can handle set error', (done) => {
+    const mockRNError = {
+      setItem(path, value, cb) {
+        cb('Error Thrown', undefined);
+      },
+    };
+    CoreManager.setAsyncStorage(mockRNError);
+    RNStorageController.setItemAsync('myKey', 'myValue').catch((error) => {
+      expect(error).toBe('Error Thrown');
+      done();
+    });
+  });
+
+  it('can handle get error', (done) => {
+    const mockRNError = {
+      getItem(path, cb) {
+        cb('Error Thrown', undefined);
+      },
+    };
+    CoreManager.setAsyncStorage(mockRNError);
+    RNStorageController.getItemAsync('myKey').catch((error) => {
+      expect(error).toBe('Error Thrown');
+      done();
+    });
+  });
+
+  it('can handle remove error', (done) => {
+    const mockRNError = {
+      removeItem(path, cb) {
+        cb('Error Thrown', undefined);
+      },
+    };
+    CoreManager.setAsyncStorage(mockRNError);
+    RNStorageController.removeItemAsync('myKey').catch((error) => {
+      expect(error).toBe('Error Thrown');
+      done();
+    });
+  });
+
+  it('can handle getAllKeys error', (done) => {
+    const mockRNError = {
+      getAllKeys(cb) {
+        cb('Error Thrown', undefined);
+      },
+    };
+    CoreManager.setAsyncStorage(mockRNError);
+    RNStorageController.getAllKeys().catch((error) => {
+      expect(error).toBe('Error Thrown');
+      done();
+    });
+  });
 });
 
 const DefaultStorageController = require('../StorageController.default');
@@ -213,6 +258,7 @@ describe('Storage (Default StorageController)', () => {
 
 describe('Storage (Async StorageController)', () => {
   beforeEach(() => {
+    CoreManager.setAsyncStorage(mockRNStorageInterface);
     CoreManager.setStorageController(
       require('../StorageController.react-native')
     );
diff --git a/src/__tests__/equals-test.js b/src/__tests__/equals-test.js
index 07d06d2..3ed942e 100644
--- a/src/__tests__/equals-test.js
+++ b/src/__tests__/equals-test.js
@@ -47,11 +47,13 @@ describe('equals', () => {
     expect(equals(a, a)).toBe(true);
     expect(equals({}, {})).toBe(true);
     expect(equals({ a: 1 }, { a: 1 })).toBe(true);
+    expect(equals({ a: 1 }, { a: 2 })).toBe(false);
     expect(equals({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(true);
     expect(equals({ a: 1, b: 2 }, { b: 2 })).toBe(false);
     expect(equals({ a: {} }, { a: {} })).toBe(true);
 
     expect(equals([], [])).toBe(true);
+    expect(equals([], {})).toBe(false);
     expect(equals([1, 2, 3], [1, 2, 3])).toBe(true);
     expect(equals([1, 2, 3], [3, 2, 1])).toBe(false);
     expect(equals([1, 2, 3], [1, 2])).toBe(false);
@@ -119,5 +121,36 @@ describe('equals', () => {
     a.id = 'myobj';
     b.id = 'myobj';
     expect(equals(a, b)).toBe(true);
+
+    const c = {
+      __type: 'Pointer',
+      className: 'Item',
+      objectId: 'myobj',
+    };
+    const d = {
+      __type: 'Object',
+      className: 'Item',
+      objectId: 'myobj',
+    };
+    const e = {
+      __type: 'Unknown',
+      className: 'Item',
+      objectId: 'myobj',
+    };
+    expect(equals(c, b)).toBe(true);
+    expect(equals(d, b)).toBe(true);
+    expect(equals(e, b)).toBe(false);
+  });
+
+  it('tests equality of Date', () => {
+    const a = new Date('2018-08-09T00:01:53.964Z');
+    const b = new Date('2018-08-10T00:00:00.000Z');
+
+    expect(equals(a, a)).toBe(true);
+    expect(equals(a, b)).toBe(false);
+    expect(equals(b, a)).toBe(false);
+
+    const c = [];
+    expect(equals(a, c)).toBe(false);
   });
 });
diff --git a/src/__tests__/test_helpers/mockLocalStorage.js b/src/__tests__/test_helpers/mockLocalStorage.js
new file mode 100644
index 0000000..eadaf97
--- /dev/null
+++ b/src/__tests__/test_helpers/mockLocalStorage.js
@@ -0,0 +1,39 @@
+/**
+ * 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.
+ */
+
+let mockStorage = {};
+const mockLocalStorage = {
+
+  getItem(path) {
+    return mockStorage[path] || null;
+  },
+
+  setItem(path, value) {
+    mockStorage[path] = value;
+  },
+
+  removeItem(path) {
+    delete mockStorage[path];
+  },
+
+  get length() {
+    return Object.keys(mockStorage).length;
+  },
+
+  key: (i) => {
+    const keys = Object.keys(mockStorage);
+    return keys[i] || null;
+  },
+
+  clear() {
+    mockStorage = {};
+  },
+};
+
+module.exports = mockLocalStorage;
diff --git a/src/__tests__/test_helpers/mockRNStorage.js b/src/__tests__/test_helpers/mockRNStorage.js
new file mode 100644
index 0000000..4e09cfa
--- /dev/null
+++ b/src/__tests__/test_helpers/mockRNStorage.js
@@ -0,0 +1,35 @@
+/**
+ * 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.
+ */
+
+let mockStorage = {};
+const mockRNStorage = {
+  getItem(path, cb) {
+    cb(undefined, mockStorage[path] || null);
+  },
+
+  setItem(path, value, cb) {
+    mockStorage[path] = value;
+    cb();
+  },
+
+  removeItem(path, cb) {
+    delete mockStorage[path];
+    cb();
+  },
+
+  getAllKeys(cb) {
+    cb(undefined, Object.keys(mockStorage));
+  },
+
+  clear() {
+    mockStorage = {};
+  },
+};
+
+module.exports = mockRNStorage;