/**
 * 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.dontMock('../CoreManager');
jest.dontMock('../encode');
jest.dontMock('../decode');
jest.dontMock('../ParseError');
jest.dontMock('../ParseGeoPoint');
jest.dontMock('../ParseQuery');
jest.dontMock('../promiseUtils');
jest.dontMock('../SingleInstanceStateController');
jest.dontMock('../UniqueInstanceStateController');
jest.dontMock('../ObjectStateMutations');
jest.dontMock('../LocalDatastore');
jest.dontMock('../OfflineQuery');
jest.dontMock('../LiveQuerySubscription');

jest.mock('../uuid', () => {
  let value = 0;
  return () => value++;
});
const mockObject = function (className) {
  this.className = className;
  this.attributes = {};
};
mockObject.registerSubclass = function () {};
mockObject.fromJSON = function (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;
};
jest.setMock('../ParseObject', mockObject);

const mockLocalDatastore = {
  _serializeObjectsFromPinName: jest.fn(),
  checkIfEnabled: jest.fn(),
};
jest.setMock('../LocalDatastore', mockLocalDatastore);

let CoreManager = require('../CoreManager');
const ParseError = require('../ParseError').default;
const ParseGeoPoint = require('../ParseGeoPoint').default;
let ParseObject = require('../ParseObject');
let ParseQuery = require('../ParseQuery').default;
const LiveQuerySubscription = require('../LiveQuerySubscription').default;

const MockRESTController = {
  request: jest.fn(),
  ajax: jest.fn(),
};

const QueryController = CoreManager.getQueryController();

import { DEFAULT_PIN } from '../LocalDatastoreUtils';

describe('ParseQuery', () => {
  beforeEach(() => {
    CoreManager.setQueryController(QueryController);
    CoreManager.setRESTController(MockRESTController);
  });

  it('can be constructed from a class name', () => {
    const q = new ParseQuery('Item');
    expect(q.className).toBe('Item');
    expect(q.toJSON()).toEqual({
      where: {},
    });
  });

  it('can be constructed from a ParseObject', () => {
    const item = new ParseObject('Item');
    const q2 = new ParseQuery(item);
    expect(q2.className).toBe('Item');
    expect(q2.toJSON()).toEqual({
      where: {},
    });
  });

  it('can be constructed from a function constructor', () => {
    function ObjectFunction() {
      this.className = 'Item';
    }
    const q = new ParseQuery(ObjectFunction);
    expect(q.className).toBe('Item');
    expect(q.toJSON()).toEqual({
      where: {},
    });
  });

  it('can be constructed from a function prototype', () => {
    function ObjectFunction() {}
    ObjectFunction.className = 'Item';
    const q = new ParseQuery(ObjectFunction);
    expect(q.className).toBe('Item');
    expect(q.toJSON()).toEqual({
      where: {},
    });
  });

  it('throws when created with invalid data', () => {
    expect(function () {
      new ParseQuery();
    }).toThrow('A ParseQuery must be constructed with a ParseObject or class name.');
  });

  it('can generate equality queries', () => {
    const q = new ParseQuery('Item');
    q.equalTo('size', 'medium');
    expect(q.toJSON()).toEqual({
      where: {
        size: 'medium',
      },
    });

    // Overrides old constraint
    q.equalTo('size', 'small');
    expect(q.toJSON()).toEqual({
      where: {
        size: 'small',
      },
    });

    // equalTo('key', undefined) resolves to 'does not exist'
    q.equalTo('size');
    expect(q.toJSON()).toEqual({
      where: {
        size: {
          $exists: false,
        },
      },
    });

    const size = 'medium';
    const stock = true;
    q.equalTo({ size, stock });
    expect(q.toJSON()).toEqual({
      where: {
        size: 'medium',
        stock: true,
      },
    });
  });

  it('can generate inequality queries', () => {
    const q = new ParseQuery('Item');
    q.notEqualTo('size', 'small');
    expect(q.toJSON()).toEqual({
      where: {
        size: {
          $ne: 'small',
        },
      },
    });

    q.notEqualTo('size', 'medium');
    expect(q.toJSON()).toEqual({
      where: {
        size: {
          $ne: 'medium',
        },
      },
    });

    const size = 'medium';
    const stock = true;
    q.notEqualTo({ size, stock });
    expect(q.toJSON()).toEqual({
      where: {
        size: {
          $ne: 'medium',
        },
        stock: {
          $ne: true,
        },
      },
    });
  });

  it('can generate less-than queries', () => {
    const q = new ParseQuery('Item');
    q.lessThan('inStock', 10);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $lt: 10,
        },
      },
    });

    q.lessThan('inStock', 4);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $lt: 4,
        },
      },
    });
  });

  it('can generate less-than-or-equal-to queries', () => {
    const q = new ParseQuery('Item');
    q.lessThanOrEqualTo('inStock', 10);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $lte: 10,
        },
      },
    });

    q.lessThanOrEqualTo('inStock', 4);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $lte: 4,
        },
      },
    });
  });

  it('can generate greater-than queries', () => {
    const q = new ParseQuery('Item');
    q.greaterThan('inStock', 0);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
    });

    q.greaterThan('inStock', 100);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 100,
        },
      },
    });
  });

  it('can generate greater-than-or-equal-to queries', () => {
    const q = new ParseQuery('Item');
    q.greaterThanOrEqualTo('inStock', 0);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gte: 0,
        },
      },
    });

    q.greaterThanOrEqualTo('inStock', 100);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gte: 100,
        },
      },
    });
  });

  it('can generate contained-in queries', () => {
    const q = new ParseQuery('Item');
    q.containedIn('size', ['small', 'medium']);
    expect(q.toJSON()).toEqual({
      where: {
        size: {
          $in: ['small', 'medium'],
        },
      },
    });

    q.containedIn('size', ['small', 'medium', 'large']);
    expect(q.toJSON()).toEqual({
      where: {
        size: {
          $in: ['small', 'medium', 'large'],
        },
      },
    });
  });

  it('can generate not-contained-in queries', () => {
    const q = new ParseQuery('Item');
    q.notContainedIn('size', ['small', 'medium']);
    expect(q.toJSON()).toEqual({
      where: {
        size: {
          $nin: ['small', 'medium'],
        },
      },
    });

    q.notContainedIn('size', ['small', 'large']);
    expect(q.toJSON()).toEqual({
      where: {
        size: {
          $nin: ['small', 'large'],
        },
      },
    });
  });

  it('can generate contains-all queries', () => {
    const q = new ParseQuery('Item');
    q.containsAll('tags', ['hot', 'sold-out']);
    expect(q.toJSON()).toEqual({
      where: {
        tags: {
          $all: ['hot', 'sold-out'],
        },
      },
    });

    q.containsAll('tags', ['sale', 'new']);
    expect(q.toJSON()).toEqual({
      where: {
        tags: {
          $all: ['sale', 'new'],
        },
      },
    });
  });

  it('can generate containedBy queries', () => {
    const q = new ParseQuery('Item');
    q.containedBy('tags', ['hot', 'sold-out']);
    expect(q.toJSON()).toEqual({
      where: {
        tags: {
          $containedBy: ['hot', 'sold-out'],
        },
      },
    });

    q.containedBy('tags', ['sale', 'new']);
    expect(q.toJSON()).toEqual({
      where: {
        tags: {
          $containedBy: ['sale', 'new'],
        },
      },
    });
  });

  it('can generate contains-all-starting-with queries', () => {
    const q = new ParseQuery('Item');
    q.containsAllStartingWith('tags', ['ho', 'out']);
    expect(q.toJSON()).toEqual({
      where: {
        tags: {
          $all: [{ $regex: '^\\Qho\\E' }, { $regex: '^\\Qout\\E' }],
        },
      },
    });

    q.containsAllStartingWith('tags', ['sal', 'ne']);
    expect(q.toJSON()).toEqual({
      where: {
        tags: {
          $all: [{ $regex: '^\\Qsal\\E' }, { $regex: '^\\Qne\\E' }],
        },
      },
    });

    q.containsAllStartingWith('tags', 'noArray');
    expect(q.toJSON()).toEqual({
      where: {
        tags: {
          $all: [{ $regex: '^\\QnoArray\\E' }],
        },
      },
    });
  });

  it('can generate exists queries', () => {
    const q = new ParseQuery('Item');
    q.exists('name');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $exists: true,
        },
      },
    });
  });

  it('can generate does-not-exist queries', () => {
    const q = new ParseQuery('Item');
    q.doesNotExist('name');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $exists: false,
        },
      },
    });
  });

  it('can generate RegExp queries', () => {
    const q = new ParseQuery('Item');
    q.matches('name', /ing$/);
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: 'ing$',
        },
      },
    });

    q.matches('name', /\bor\b/, 'i');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '\\bor\\b',
          $options: 'i',
        },
      },
    });

    q.matches('name', /\bor\b/i);
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '\\bor\\b',
          $options: 'i',
        },
      },
    });

    q.matches('name', /\bor\b/im);
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '\\bor\\b',
          $options: 'im',
        },
      },
    });
  });

  it('can generate queries that match results from other queries', () => {
    const q1 = new ParseQuery('Item');
    q1.equalTo('inStock', 0);

    const q2 = new ParseQuery('Purchase');
    q2.matchesQuery('item', q1);
    expect(q2.toJSON()).toEqual({
      where: {
        item: {
          $inQuery: {
            className: 'Item',
            where: {
              inStock: 0,
            },
          },
        },
      },
    });
  });

  it("can generate queries that don't match results from other queries", () => {
    const q1 = new ParseQuery('Item');
    q1.equalTo('inStock', 0);

    const q2 = new ParseQuery('Purchase');
    q2.doesNotMatchQuery('item', q1);
    expect(q2.toJSON()).toEqual({
      where: {
        item: {
          $notInQuery: {
            className: 'Item',
            where: {
              inStock: 0,
            },
          },
        },
      },
    });
  });

  it('can generate queries that match keys from other queries', () => {
    const q1 = new ParseQuery('Item');
    q1.equalTo('inStock', 0);

    const q2 = new ParseQuery('Review');
    q2.matchesKeyInQuery('itemName', 'name', q1);
    expect(q2.toJSON()).toEqual({
      where: {
        itemName: {
          $select: {
            key: 'name',
            query: {
              className: 'Item',
              where: {
                inStock: 0,
              },
            },
          },
        },
      },
    });
  });

  it("can generate queries that don't match keys from other queries", () => {
    const q1 = new ParseQuery('Item');
    q1.equalTo('inStock', 0);

    const q2 = new ParseQuery('Review');
    q2.doesNotMatchKeyInQuery('itemName', 'name', q1);
    expect(q2.toJSON()).toEqual({
      where: {
        itemName: {
          $dontSelect: {
            key: 'name',
            query: {
              className: 'Item',
              where: {
                inStock: 0,
              },
            },
          },
        },
      },
    });
  });

  it('can generate string-contains queries', () => {
    const q = new ParseQuery('Item');
    expect(q.contains.bind(q, 'name', 12)).toThrow(
      'The value being searched for must be a string.'
    );

    q.contains('name', ' or ');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '\\Q or \\E',
        },
      },
    });

    // Test escaping in quote()
    q.contains('name', 'slash-E \\E');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '\\Qslash-E \\E\\\\E\\Q\\E',
        },
      },
    });

    q.contains('name', 'slash-Q \\Q');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '\\Qslash-Q \\Q\\E',
        },
      },
    });
  });

  it('can generate string-starts-with queries', () => {
    const q = new ParseQuery('Item');
    expect(q.startsWith.bind(q, 'name', 12)).toThrow(
      'The value being searched for must be a string.'
    );

    q.startsWith('name', 'Abc');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '^\\QAbc\\E',
        },
      },
    });

    q.startsWith('name', 'Def');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '^\\QDef\\E',
        },
      },
    });
  });

  it('can generate string-ends-with queries', () => {
    const q = new ParseQuery('Item');
    expect(q.endsWith.bind(q, 'name', 12)).toThrow(
      'The value being searched for must be a string.'
    );

    q.endsWith('name', 'XYZ');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '\\QXYZ\\E$',
        },
      },
    });

    q.endsWith('name', 'xyz');
    expect(q.toJSON()).toEqual({
      where: {
        name: {
          $regex: '\\Qxyz\\E$',
        },
      },
    });
  });

  it('can generate near-geopoint queries', () => {
    const q = new ParseQuery('Shipment');
    q.near('shippedTo', new ParseGeoPoint(10, 20));
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $nearSphere: {
            __type: 'GeoPoint',
            latitude: 10,
            longitude: 20,
          },
        },
      },
    });

    q.near('shippedTo', [30, 40]);
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $nearSphere: {
            __type: 'GeoPoint',
            latitude: 30,
            longitude: 40,
          },
        },
      },
    });

    // GeoPoint's internal fallback
    q.near('shippedTo', 'string');
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $nearSphere: {
            __type: 'GeoPoint',
            latitude: 0,
            longitude: 0,
          },
        },
      },
    });
  });

  it('can generate near-geopoint queries with ranges', () => {
    const q = new ParseQuery('Shipment');
    q.withinRadians('shippedTo', [20, 40], 2, true);
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $nearSphere: {
            __type: 'GeoPoint',
            latitude: 20,
            longitude: 40,
          },
          $maxDistance: 2,
        },
      },
    });

    q.withinMiles('shippedTo', [20, 30], 3958.8, true);
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $nearSphere: {
            __type: 'GeoPoint',
            latitude: 20,
            longitude: 30,
          },
          $maxDistance: 1,
        },
      },
    });

    q.withinKilometers('shippedTo', [30, 30], 6371.0, true);
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $nearSphere: {
            __type: 'GeoPoint',
            latitude: 30,
            longitude: 30,
          },
          $maxDistance: 1,
        },
      },
    });
  });

  it('can generate near-geopoint queries without sorting', () => {
    const q = new ParseQuery('Shipment');
    q.withinRadians('shippedTo', new ParseGeoPoint(20, 40), 2, false);
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $geoWithin: {
            $centerSphere: [
              [40, 20], // This takes [lng, lat] vs. ParseGeoPoint [lat, lng].
              2,
            ],
          },
        },
      },
    });

    q.withinMiles('shippedTo', new ParseGeoPoint(20, 30), 3958.8, false);
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $geoWithin: {
            $centerSphere: [
              [30, 20], // This takes [lng, lat] vs. ParseGeoPoint [lat, lng].
              1,
            ],
          },
        },
      },
    });

    q.withinKilometers('shippedTo', new ParseGeoPoint(30, 30), 6371.0, false);
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $geoWithin: {
            $centerSphere: [
              [30, 30], // This takes [lng, lat] vs. ParseGeoPoint [lat, lng].
              1,
            ],
          },
        },
      },
    });
  });

  it('can generate geobox queries', () => {
    const q = new ParseQuery('Shipment');
    q.withinGeoBox('shippedTo', [20, 20], [10, 30]);
    expect(q.toJSON()).toEqual({
      where: {
        shippedTo: {
          $within: {
            $box: [
              {
                __type: 'GeoPoint',
                latitude: 20,
                longitude: 20,
              },
              {
                __type: 'GeoPoint',
                latitude: 10,
                longitude: 30,
              },
            ],
          },
        },
      },
    });
  });

  it('can combine multiple clauses', () => {
    const q = new ParseQuery('Item');
    q.lessThan('inStock', 10);
    q.greaterThan('inStock', 0);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $lt: 10,
          $gt: 0,
        },
      },
    });

    q.containedIn('size', ['small', 'medium']);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $lt: 10,
          $gt: 0,
        },
        size: {
          $in: ['small', 'medium'],
        },
      },
    });
  });

  it('can specify ordering', () => {
    const q = new ParseQuery('Item');
    q.greaterThan('inStock', 0).ascending('createdAt');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: 'createdAt',
    });

    // overrides
    q.ascending('name');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: 'name',
    });

    q.ascending('name');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: 'name',
    });

    // removes whitespace
    q.ascending('  createdAt');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: 'createdAt',
    });

    // add additional ordering
    q.addAscending('name');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: 'createdAt,name',
    });

    q.ascending(['a', 'b', 'c']);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: 'a,b,c',
    });

    q.ascending('name', 'createdAt');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: 'name,createdAt',
    });

    q.descending('createdAt');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: '-createdAt',
    });

    q.addAscending('name');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: '-createdAt,name',
    });

    q.addDescending('a', 'b', 'c');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: '-createdAt,name,-a,-b,-c',
    });

    q.descending(['a', 'b']);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      order: '-a,-b',
    });

    const q2 = new ParseQuery('Item');
    q2.addDescending();
    expect(q2.toJSON()).toEqual({
      where: {},
      order: '',
    });

    const q3 = new ParseQuery('Item');
    q3.addAscending();
    expect(q3.toJSON()).toEqual({
      where: {},
      order: '',
    });
  });

  it('can establish skip counts', () => {
    const q = new ParseQuery('Item');
    expect(q.skip.bind(q, 'string')).toThrow('You can only skip by a positive number');
    expect(q.skip.bind(q, -5)).toThrow('You can only skip by a positive number');

    q.skip(4);
    expect(q.toJSON()).toEqual({
      where: {},
      skip: 4,
    });
    q.equalTo('name', 'Product 5');
    expect(q.toJSON()).toEqual({
      where: {
        name: 'Product 5',
      },
      skip: 4,
    });
  });

  it('can establish result limits', () => {
    const q = new ParseQuery('Item');
    expect(q.limit.bind(q, 'string')).toThrow('You can only set the limit to a numeric value');

    q.limit(10);
    expect(q.toJSON()).toEqual({
      where: {},
      limit: 10,
    });
    q.limit(-1);
    expect(q.toJSON()).toEqual({
      where: {},
    });
  });

  it('can set withCount flag in find query', () => {
    const q = new ParseQuery('Item');
    expect(q.withCount.bind(q, 'string')).toThrow('You can only set withCount to a boolean value');

    q.withCount(true);
    expect(q.toJSON()).toEqual({
      where: {},
      count: 1,
    });
    q.withCount(false);
    expect(q.toJSON()).toEqual({
      where: {},
    });
  });

  it('can set hint value', () => {
    const q = new ParseQuery('Item');
    q.hint('_id_');
    expect(q.toJSON()).toEqual({
      where: {},
      hint: '_id_',
    });
  });

  it('can set explain value', () => {
    const q = new ParseQuery('Item');
    q.explain();
    const json = q.toJSON();
    expect(json).toEqual({
      where: {},
      explain: true,
    });
    const q2 = new ParseQuery('Item');
    q2.withJSON(json);
    expect(q2._explain).toBe(true);

    q.explain(false);
    expect(q.toJSON()).toEqual({
      where: {},
    });
    expect(q.explain.bind(q, 'not boolean')).toThrow('You can only set explain to a boolean value');
  });

  it('can generate queries that include full data for pointers', () => {
    const q = new ParseQuery('Item');
    q.greaterThan('inStock', 0);
    q.include('manufacturer');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      include: 'manufacturer',
    });

    q.include('previousModel', 'nextModel');
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      include: 'manufacturer,previousModel,nextModel',
    });

    q.include(['lastPurchaser', 'lastPurchase']);
    expect(q.toJSON()).toEqual({
      where: {
        inStock: {
          $gt: 0,
        },
      },
      include: 'manufacturer,previousModel,nextModel,lastPurchaser,lastPurchase',
    });
  });

  it('can includeAll for pointers', () => {
    const q = new ParseQuery('Item');
    q.includeAll();
    const json = q.toJSON();
    expect(json).toEqual({
      where: {},
      include: '*',
    });
    const q2 = new ParseQuery('Item');
    q2.withJSON(json);
    expect(q2._include).toEqual(['*']);
  });

  it('can exclude keys', () => {
    const q = new ParseQuery('Item');
    q.exclude('foo');
    const json = q.toJSON();
    expect(json).toEqual({
      where: {},
      excludeKeys: 'foo',
    });
    const q2 = new ParseQuery('Item');
    q2.withJSON(json);
    expect(q2._exclude).toEqual(['foo']);
  });

  it('can exclude multiple keys', () => {
    const q = new ParseQuery('Item');
    q.exclude(['foo', 'bar']);
    const json = q.toJSON();
    expect(json).toEqual({
      where: {},
      excludeKeys: 'foo,bar',
    });
    const q2 = new ParseQuery('Item');
    q2.withJSON(json);
    expect(q2._exclude).toEqual(['foo', 'bar']);
  });

  it('can use extraOptions', () => {
    const q = new ParseQuery('Item');
    q._extraOptions.randomOption = 'test';
    const json = q.toJSON();
    expect(json).toEqual({
      where: {},
      randomOption: 'test',
    });
    const q2 = new ParseQuery('Item');
    q2.withJSON(json);
    expect(q2._extraOptions.randomOption).toBe('test');
  });

  it('can use hint', () => {
    const q = new ParseQuery('Item');
    q.hint('_id_');
    const json = q.toJSON();
    expect(json).toEqual({
      where: {},
      hint: '_id_',
    });
    const q2 = new ParseQuery('Item');
    q2.withJSON(json);
    expect(q2._hint).toBe('_id_');
  });

  it('can specify certain fields to send back', () => {
    const q = new ParseQuery('Item');
    q.select('size');
    expect(q.toJSON()).toEqual({
      where: {},
      keys: 'size',
    });

    q.select('inStock', 'lastPurchase');
    expect(q.toJSON()).toEqual({
      where: {},
      keys: 'size,inStock,lastPurchase',
    });

    q.select(['weight', 'color']);
    expect(q.toJSON()).toEqual({
      where: {},
      keys: 'size,inStock,lastPurchase,weight,color',
    });
  });

  it('can combine queries with an OR clause', () => {
    const q = new ParseQuery('Item');
    let q2 = new ParseQuery('Purchase');
    expect(ParseQuery.or.bind(null, q, q2)).toThrow('All queries must be for the same class.');

    q2 = new ParseQuery('Item');
    q.equalTo('size', 'medium');
    q2.equalTo('size', 'large');

    let mediumOrLarge = ParseQuery.or(q, q2);
    expect(mediumOrLarge.toJSON()).toEqual({
      where: {
        $or: [{ size: 'medium' }, { size: 'large' }],
      },
    });

    // It removes limits, skips, etc
    q.skip(10);
    mediumOrLarge = ParseQuery.or(q, q2);
    expect(mediumOrLarge.toJSON()).toEqual({
      where: {
        $or: [{ size: 'medium' }, { size: 'large' }],
      },
    });
  });

  it('can combine queries with an AND clause', () => {
    const q = new ParseQuery('Item');
    let q2 = new ParseQuery('Purchase');
    expect(ParseQuery.and.bind(null, q, q2)).toThrow('All queries must be for the same class.');

    q2 = new ParseQuery('Item');
    q.equalTo('size', 'medium');
    q2.equalTo('size', 'large');

    let mediumOrLarge = ParseQuery.and(q, q2);
    expect(mediumOrLarge.toJSON()).toEqual({
      where: {
        $and: [{ size: 'medium' }, { size: 'large' }],
      },
    });

    // It removes limits, skips, etc
    q.limit(10);
    mediumOrLarge = ParseQuery.and(q, q2);
    expect(mediumOrLarge.toJSON()).toEqual({
      where: {
        $and: [{ size: 'medium' }, { size: 'large' }],
      },
    });
  });

  it('can combine queries with a NOR clause', () => {
    const q = new ParseQuery('Item');
    let q2 = new ParseQuery('Purchase');
    expect(ParseQuery.nor.bind(null, q, q2)).toThrow('All queries must be for the same class.');

    q2 = new ParseQuery('Item');
    q.equalTo('size', 'medium');
    q2.equalTo('size', 'large');

    let mediumOrLarge = ParseQuery.nor(q, q2);
    expect(mediumOrLarge.toJSON()).toEqual({
      where: {
        $nor: [{ size: 'medium' }, { size: 'large' }],
      },
    });

    // It removes limits, skips, etc
    q.limit(10);
    mediumOrLarge = ParseQuery.nor(q, q2);
    expect(mediumOrLarge.toJSON()).toEqual({
      where: {
        $nor: [{ size: 'medium' }, { size: 'large' }],
      },
    });
  });

  it('can get the first object of a query', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 1,
          where: {
            size: 'small',
          },
        });
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [{ objectId: 'I1', size: 'small', name: 'Product 3' }],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.equalTo('size', 'small')
      .first()
      .then(obj => {
        expect(obj instanceof ParseObject).toBe(true);
        expect(obj.className).toBe('Item');
        expect(obj.id).toBe('I1');
        expect(obj.attributes).toEqual({
          size: 'small',
          name: 'Product 3',
        });
        done();
      });
  });

  it('can pass options to a first() query', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 1,
          where: {
            size: 'small',
          },
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        return Promise.resolve({
          results: [],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.equalTo('size', 'small')
      .first({
        useMasterKey: true,
        sessionToken: '1234',
      })
      .then(obj => {
        expect(obj).toBe(undefined);
        done();
      });
  });

  it('can handle explain query', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          explain: true,
          where: {
            size: 'small',
          },
        });
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: {
            objectId: 'I1',
            size: 'small',
            name: 'Product 3',
          },
        });
      },
    });

    const q = new ParseQuery('Item');
    q.explain();
    q.equalTo('size', 'small')
      .find()
      .then(result => {
        expect(result.objectId).toBe('I1');
        expect(result.size).toBe('small');
        expect(result.name).toEqual('Product 3');
        done();
      });
  });

  it('can get a single object by id', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 1,
          where: {
            objectId: 'I27',
          },
        });
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [{ objectId: 'I27', size: 'large', name: 'Product 27' }],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.get('I27').then(obj => {
      expect(obj instanceof ParseObject).toBe(true);
      expect(obj.className).toBe('Item');
      expect(obj.id).toBe('I27');
      expect(obj.attributes).toEqual({
        size: 'large',
        name: 'Product 27',
      });
      done();
    });
  });

  it('can return raw json from query', async () => {
    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [
            {
              objectId: 'I1',
              size: 'small',
              name: 'Product 3',
            },
          ],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.equalTo('size', 'small');
    const results = await q.find({ json: true });
    expect(results[0].objectId).toBe('I1');
    expect(results[0].size).toBe('small');
    expect(results[0].name).toEqual('Product 3');
    expect(results[0].className).toEqual('Item');

    let result = await q.first({ json: true });
    expect(result.objectId).toBe('I1');
    expect(result.size).toBe('small');
    expect(result.name).toEqual('Product 3');
    expect(result.className).toEqual('Item');

    result = await q.get(result.objectId, { json: true });
    expect(result.objectId).toBe('I1');
    expect(result.size).toBe('small');
    expect(result.name).toEqual('Product 3');
    expect(result.className).toEqual('Item');
  });

  it('will error when getting a nonexistent object', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 1,
          where: {
            objectId: 'I28',
          },
        });
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.get('I28').then(
      () => {
        // Should not be reached
        expect(true).toBe(false);
        done();
      },
      err => {
        expect(err.code).toBe(ParseError.OBJECT_NOT_FOUND);
        expect(err.message).toBe('Object not found.');
        done();
      }
    );
  });

  it('can pass options to a get() query', done => {
    const context = { a: 'a' };
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 1,
          where: {
            objectId: 'I27',
          },
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        expect(options.context).toEqual(context);
        return Promise.resolve({
          results: [{ objectId: 'I27', size: 'large', name: 'Product 27' }],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.get('I27', {
      useMasterKey: true,
      sessionToken: '1234',
      context: context,
    }).then(() => {
      done();
    });
  });

  it('can issue a count query', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 0,
          count: 1,
          where: {
            size: 'small',
          },
        });
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [],
          count: 145,
        });
      },
    });

    const q = new ParseQuery('Item');
    q.equalTo('size', 'small')
      .count()
      .then(count => {
        expect(count).toBe(145);
        done();
      });
  });

  it('can pass options to a count query', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 0,
          count: 1,
          where: {
            size: 'small',
          },
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        return Promise.resolve({
          results: [],
          count: 145,
        });
      },
    });

    const q = new ParseQuery('Item');
    q.equalTo('size', 'small')
      .count({
        useMasterKey: true,
        sessionToken: '1234',
      })
      .then(count => {
        expect(count).toBe(145);
        done();
      });
  });

  it('can issue a query to the controller', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 2,
          skip: 8,
          keys: 'size,name',
          order: 'createdAt',
          where: {
            size: {
              $in: ['small', 'medium'],
            },
          },
          readPreference: 'PRIMARY',
          includeReadPreference: 'SECONDARY',
          subqueryReadPreference: 'SECONDARY_PREFERRED',
        });
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [
            { objectId: 'I55', size: 'medium', name: 'Product 55' },
            { objectId: 'I89', size: 'small', name: 'Product 89' },
          ],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.containedIn('size', ['small', 'medium'])
      .limit(2)
      .skip(8)
      .ascending('createdAt')
      .select('size', 'name')
      .readPreference('PRIMARY', 'SECONDARY', 'SECONDARY_PREFERRED')
      .find()
      .then(objs => {
        expect(objs.length).toBe(2);
        expect(objs[0] instanceof ParseObject).toBe(true);
        expect(objs[0].attributes).toEqual({
          size: 'medium',
          name: 'Product 55',
        });
        expect(objs[1] instanceof ParseObject).toBe(true);
        expect(objs[1].attributes).toEqual({
          size: 'small',
          name: 'Product 89',
        });
        done();
      });
  });

  it('can pass options to find()', done => {
    const context = { a: 'a' };
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          where: {
            size: {
              $in: ['small', 'medium'],
            },
          },
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        expect(options.context).toEqual(context);
        return Promise.resolve({
          results: [],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.containedIn('size', ['small', 'medium'])
      .find({
        useMasterKey: true,
        sessionToken: '1234',
        context: context,
      })
      .then(objs => {
        expect(objs).toEqual([]);
        done();
      });
  });

  it('can receive both count and objects from find() using withCount flag', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          where: {},
          count: 1,
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        return Promise.resolve({
          results: [
            { objectId: '1', name: 'Product 55' },
            { objectId: '2', name: 'Product 89' },
          ],
          count: 2,
        });
      },
    });

    const q = new ParseQuery('Item');
    q.withCount(true)
      .find({
        useMasterKey: true,
        sessionToken: '1234',
      })
      .then(obj => {
        expect(obj.results).toBeDefined();
        expect(obj.results.length).toBe(2);
        expect(obj.count).toBeDefined();
        expect(typeof obj.count).toBe('number');
        done();
      });
  });

  describe('iterating over batches with .eachBatch()', () => {
    let findMock;
    beforeEach(() => {
      findMock = jest.fn();
      findMock.mockReturnValueOnce(
        Promise.resolve({
          results: [
            { objectId: 'I55', size: 'medium', name: 'Product 55' },
            { objectId: 'I89', size: 'small', name: 'Product 89' },
          ],
        })
      );
      findMock.mockReturnValueOnce(
        Promise.resolve({
          results: [{ objectId: 'I91', size: 'small', name: 'Product 91' }],
        })
      );
      CoreManager.setQueryController({
        aggregate() {},
        find: findMock,
      });
    });

    it('passes query attributes through to the REST API', async () => {
      const q = new ParseQuery('Item');
      q.containedIn('size', ['small', 'medium']);
      q.matchesKeyInQuery('name', 'productName', new ParseQuery('Review').equalTo('stars', 5));
      q.equalTo('valid', true);
      q.equalTo('arrayField', ['a', 'b']);
      q.select('size', 'name');
      q.includeAll();
      q.hint('_id_');

      await q.eachBatch(() => {});

      expect(findMock).toHaveBeenCalledTimes(1);
      const [className, params, options] = findMock.mock.calls[0];
      expect(className).toBe('Item');
      expect(params).toEqual({
        limit: 100,
        order: 'objectId',
        keys: 'size,name',
        include: '*',
        hint: '_id_',
        where: {
          arrayField: ['a', 'b'],
          size: {
            $in: ['small', 'medium'],
          },
          name: {
            $select: {
              key: 'productName',
              query: {
                className: 'Review',
                where: {
                  stars: 5,
                },
              },
            },
          },
          valid: true,
        },
      });
      expect(options.requestTask).toBeDefined();
    });

    it('passes options through to the REST API', async () => {
      const batchOptions = {
        useMasterKey: true,
        sessionToken: '1234',
        batchSize: 50,
      };
      const q = new ParseQuery('Item');
      await q.eachBatch(() => {}, batchOptions);
      expect(findMock).toHaveBeenCalledTimes(1);
      const [className, params, options] = findMock.mock.calls[0];
      expect(className).toBe('Item');
      expect(params).toEqual({
        limit: 50,
        order: 'objectId',
        where: {},
      });
      expect(options.useMasterKey).toBe(true);
      expect(options.sessionToken).toEqual('1234');
    });

    it('only makes one request when the results fit in one page', async () => {
      const q = new ParseQuery('Item');
      await q.eachBatch(() => {});
      expect(findMock).toHaveBeenCalledTimes(1);
    });

    it('makes more requests when the results do not fit in one page', async () => {
      const q = new ParseQuery('Item');
      await q.eachBatch(() => {}, { batchSize: 2 });
      expect(findMock).toHaveBeenCalledTimes(2);
    });

    it('stops iteration when the callback returns a promise that rejects', async () => {
      let callCount = 0;
      const callback = () => {
        callCount++;
        return Promise.reject(new Error('Callback rejecting'));
      };
      const q = new ParseQuery('Item');
      await q.eachBatch(callback, { batchSize: 2 }).catch(() => {});
      expect(callCount).toBe(1);
    });

    it('handles a synchronous callback', async () => {
      const results = [];
      const q = new ParseQuery('Item');
      await q.eachBatch(items => {
        items.map(item => results.push(item.attributes.size));
      });
      expect(results).toEqual(['medium', 'small']);
    });

    it('handles an asynchronous callback', async () => {
      const results = [];
      const q = new ParseQuery('Item');
      await q.eachBatch(items => {
        items.map(item => results.push(item.attributes.size));
        return new Promise(resolve => setImmediate(resolve));
      });
      expect(results).toEqual(['medium', 'small']);
    });
  });

  describe('return all objects with .findAll()', () => {
    let findMock;
    beforeEach(() => {
      findMock = jest.fn();
      findMock.mockReturnValueOnce(
        Promise.resolve({
          results: [
            { objectId: 'I55', size: 'medium', name: 'Product 55' },
            { objectId: 'I89', size: 'small', name: 'Product 89' },
          ],
        })
      );
      findMock.mockReturnValueOnce(
        Promise.resolve({
          results: [{ objectId: 'I91', size: 'small', name: 'Product 91' }],
        })
      );
      CoreManager.setQueryController({
        aggregate() {},
        find: findMock,
      });
    });

    it('passes query attributes through to the REST API', async () => {
      const q = new ParseQuery('Item');
      q.containedIn('size', ['small', 'medium']);
      q.matchesKeyInQuery('name', 'productName', new ParseQuery('Review').equalTo('stars', 5));
      q.equalTo('valid', true);
      q.select('size', 'name');
      q.includeAll();
      q.hint('_id_');

      await q.findAll();
      expect(findMock).toHaveBeenCalledTimes(1);
      const [className, params, options] = findMock.mock.calls[0];
      expect(className).toBe('Item');
      expect(params).toEqual({
        limit: 100,
        order: 'objectId',
        keys: 'size,name',
        include: '*',
        hint: '_id_',
        where: {
          size: {
            $in: ['small', 'medium'],
          },
          name: {
            $select: {
              key: 'productName',
              query: {
                className: 'Review',
                where: {
                  stars: 5,
                },
              },
            },
          },
          valid: true,
        },
      });
      expect(options.requestTask).toBeDefined();
    });

    it('passes options through to the REST API', async () => {
      const batchOptions = {
        useMasterKey: true,
        sessionToken: '1234',
        batchSize: 50,
      };
      const q = new ParseQuery('Item');
      await q.findAll(batchOptions);
      expect(findMock).toHaveBeenCalledTimes(1);
      const [className, params, options] = findMock.mock.calls[0];
      expect(className).toBe('Item');
      expect(params).toEqual({
        limit: 50,
        order: 'objectId',
        where: {},
      });
      expect(options.useMasterKey).toBe(true);
      expect(options.sessionToken).toEqual('1234');
    });

    it('only makes one request when the results fit in one page', async () => {
      const q = new ParseQuery('Item');
      await q.findAll();
      expect(findMock).toHaveBeenCalledTimes(1);
    });

    it('makes more requests when the results do not fit in one page', async () => {
      const q = new ParseQuery('Item');
      await q.findAll({ batchSize: 2 });
      expect(findMock).toHaveBeenCalledTimes(2);
    });

    it('Returns all objects', async () => {
      const q = new ParseQuery('Item');
      const results = await q.findAll();
      expect(results.map(obj => obj.attributes.size)).toEqual(['medium', 'small']);
    });
  });

  it('can iterate over results with each()', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 100,
          order: 'objectId',
          keys: 'size,name',
          include: '*',
          where: {
            size: {
              $in: ['small', 'medium'],
            },
            name: {
              $select: {
                key: 'productName',
                query: {
                  className: 'Review',
                  where: {
                    stars: 5,
                  },
                },
              },
            },
            valid: true,
          },
        });
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [
            { objectId: 'I55', size: 'medium', name: 'Product 55' },
            { objectId: 'I89', size: 'small', name: 'Product 89' },
            { objectId: 'I91', size: 'small', name: 'Product 91' },
          ],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.containedIn('size', ['small', 'medium']);
    q.matchesKeyInQuery('name', 'productName', new ParseQuery('Review').equalTo('stars', 5));
    q.equalTo('valid', true);
    q.select('size', 'name');
    q.includeAll();
    let calls = 0;

    q.each(() => {
      calls++;
    }).then(() => {
      expect(calls).toBe(3);
      done();
    });
  });

  it('can pass options to each()', done => {
    const context = { a: 'a' };
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 100,
          order: 'objectId',
          keys: 'size,name',
          where: {
            size: {
              $in: ['small', 'medium'],
            },
            valid: true,
          },
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        expect(options.context).toEqual(context);
        return Promise.resolve({
          results: [
            { objectId: 'I55', size: 'medium', name: 'Product 55' },
            { objectId: 'I89', size: 'small', name: 'Product 89' },
            { objectId: 'I91', size: 'small', name: 'Product 91' },
          ],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.containedIn('size', ['small', 'medium']);
    q.equalTo('valid', true);
    q.select('size', 'name');
    let calls = 0;

    q.each(
      () => {
        calls++;
      },
      {
        useMasterKey: true,
        sessionToken: '1234',
        context: context,
      }
    ).then(() => {
      expect(calls).toBe(3);
      done();
    });
  });

  it('can pass options to each() with hint', done => {
    const context = { a: 'a' };
    CoreManager.setQueryController({
      aggregate() {},
      find(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          limit: 100,
          order: 'objectId',
          keys: 'size,name',
          where: {
            size: {
              $in: ['small', 'medium'],
            },
            valid: true,
          },
          hint: '_id_',
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        expect(options.context).toEqual(context);
        return Promise.resolve({
          results: [
            { objectId: 'I55', size: 'medium', name: 'Product 55' },
            { objectId: 'I89', size: 'small', name: 'Product 89' },
            { objectId: 'I91', size: 'small', name: 'Product 91' },
          ],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.containedIn('size', ['small', 'medium']);
    q.equalTo('valid', true);
    q.select('size', 'name');
    q.hint('_id_');
    let calls = 0;

    q.each(
      () => {
        calls++;
      },
      {
        useMasterKey: true,
        sessionToken: '1234',
        context: context,
      }
    ).then(() => {
      expect(calls).toBe(3);
      done();
    });
  });

  it('should add hint as string', () => {
    const q = new ParseQuery('Item');
    q.hint('_id_');
    expect(q._hint).toBe('_id_');
  });

  it('should set hint as object', () => {
    const q = new ParseQuery('Item');
    q.hint({ _id: 1 });
    expect(q._hint).toStrictEqual({ _id: 1 });
  });

  it('should delete hint when set as undefined', () => {
    const q = new ParseQuery('Item');
    q.hint('_id_');
    expect(q._hint).toBe('_id_');
    q.hint();
    expect(q._hint).toBeUndefined();
  });

  describe('iterating over results via .map()', () => {
    beforeEach(() => {
      CoreManager.setQueryController({
        aggregate() {},
        find() {
          return Promise.resolve({
            results: [
              { objectId: 'I55', size: 'medium', name: 'Product 55' },
              { objectId: 'I89', size: 'small', name: 'Product 89' },
              { objectId: 'I91', size: 'small', name: 'Product 91' },
            ],
          });
        },
      });
    });

    it('can iterate with a synchronous callback', async () => {
      const callback = object => object.attributes.size;
      const q = new ParseQuery('Item');
      const results = await q.map(callback);
      expect(results).toEqual(['medium', 'small', 'small']);
    });

    it('can iterate with an asynchronous callback', async () => {
      const callback = async object => object.attributes.size;
      const q = new ParseQuery('Item');
      const results = await q.map(callback);
      expect(results).toEqual(['medium', 'small', 'small']);
    });

    it('stops iteration when a rejected promise is returned', async () => {
      let callCount = 0;
      await new ParseQuery('Item')
        .map(() => {
          callCount++;
          return Promise.reject(new Error('Callback rejecting'));
        })
        .catch(() => {});
      expect(callCount).toEqual(1);
    });
  });

  describe('iterating over results with .reduce()', () => {
    beforeEach(() => {
      CoreManager.setQueryController({
        aggregate() {},
        find() {
          return Promise.resolve({
            results: [
              { objectId: 'I55', number: 1 },
              { objectId: 'I89', number: 2 },
              { objectId: 'I91', number: 3 },
            ],
          });
        },
      });
    });

    it('can iterate with a synchronous callback', async () => {
      const callback = (accumulator, object) => accumulator + object.attributes.number;
      const q = new ParseQuery('Item');
      const result = await q.reduce(callback, 0);
      expect(result).toBe(6);
    });

    it('can iterate with an asynchronous callback', async () => {
      const callback = async (accumulator, object) => accumulator + object.attributes.number;
      const q = new ParseQuery('Item');
      const result = await q.reduce(callback, 0);
      expect(result).toBe(6);
    });

    it('stops iteration when a rejected promise is returned', async () => {
      let callCount = 0;
      const callback = () => {
        callCount += 1;
        return Promise.reject(new Error('Callback rejecting'));
      };
      const q = new ParseQuery('Item');
      await q.reduce(callback, 0).catch(() => {});
      expect(callCount).toBe(1);
    });

    it('uses the first object as an initial value when no initial value is passed', async () => {
      let callCount = 0;
      const callback = (accumulator, object) => {
        callCount += 1;
        accumulator.attributes.number += object.attributes.number;
        return accumulator;
      };
      const q = new ParseQuery('Item');
      const result = await q.reduce(callback);
      expect(result.id).toBe('I55');
      expect(result.attributes.number).toBe(6);
      expect(callCount).toBe(2); // Not called for the first object when used as initial value
    });

    it('rejects with a TypeError when there are no results and no initial value was provided', async () => {
      CoreManager.setQueryController({
        aggregate() {},
        find() {
          return Promise.resolve({ results: [] });
        },
      });

      const q = new ParseQuery('Item');
      const callback = (accumulator, object) => {
        accumulator.attributes.number += object.attributes.number;
        return accumulator;
      };
      return expect(q.reduce(callback)).rejects.toThrow(TypeError);
    });
  });

  describe('iterating over results with .filter()', () => {
    beforeEach(() => {
      CoreManager.setQueryController({
        aggregate() {},
        find() {
          return Promise.resolve({
            results: [
              { objectId: 'I55', size: 'medium', name: 'Product 55' },
              { objectId: 'I89', size: 'small', name: 'Product 89' },
              { objectId: 'I91', size: 'small', name: 'Product 91' },
            ],
          });
        },
      });
    });

    it('can iterate results with a synchronous callback', async () => {
      const callback = object => object.attributes.size === 'small';
      const q = new ParseQuery('Item');
      const results = await q.filter(callback);
      expect(results.length).toBe(2);
    });

    it('can iterate results with an async callback', async () => {
      const callback = async object => object.attributes.size === 'small';
      const q = new ParseQuery('Item');
      const results = await q.filter(callback);
      expect(results.length).toBe(2);
    });

    it('stops iteration when a rejected promise is returned', async () => {
      let callCount = 0;
      const callback = async () => {
        callCount += 1;
        return Promise.reject(new Error('Callback rejecting'));
      };
      const q = new ParseQuery('Item');
      await q.filter(callback).catch(() => {});
      expect(callCount).toBe(1);
    });
  });

  it('returns an error when iterating over an invalid query', done => {
    const q = new ParseQuery('Item');
    q.limit(10);
    q.each(() => {}).then(
      () => {
        // this should not be reached
        expect(true).toBe(false);
        done();
      },
      err => {
        expect(err).toBe('Cannot iterate on a query with sort, skip, or limit.');
        done();
      }
    );
  });

  it('rewrites User queries when the rewrite is enabled', () => {
    CoreManager.set('PERFORM_USER_REWRITE', true);
    let q = new ParseQuery('User');
    expect(q.className).toBe('_User');
    CoreManager.set('PERFORM_USER_REWRITE', false);
    q = new ParseQuery('User');
    expect(q.className).toBe('User');
  });

  it('does not override the className if it comes from the server', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [{ className: 'Product', objectId: 'P40', name: 'Product 40' }],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.find().then(results => {
      expect(results[0].className).toBe('Product');
      done();
    });
  });

  it('can override the className with a name from the server', done => {
    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [{ objectId: 'P41', name: 'Product 41' }],
          className: 'Product',
        });
      },
    });

    const q = new ParseQuery('Item');
    q.find().then(results => {
      expect(results[0].className).toBe('Product');
      done();
    });
  });

  it('overrides cached object with query results', done => {
    jest.dontMock('../ParseObject');
    jest.resetModules();
    ParseObject = require('../ParseObject').default;
    CoreManager = require('../CoreManager');
    ParseQuery = require('../ParseQuery').default;

    ParseObject.enableSingleInstance();

    let objectToReturn = {
      objectId: 'T01',
      name: 'Name',
      other: 'other',
      className: 'Thing',
      createdAt: '2017-01-10T10:00:00Z',
    };

    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [objectToReturn],
        });
      },
    });

    const q = new ParseQuery('Thing');
    let testObject;
    q.find()
      .then(results => {
        testObject = results[0];

        expect(testObject.get('name')).toBe('Name');
        expect(testObject.get('other')).toBe('other');

        objectToReturn = { objectId: 'T01', name: 'Name2' };
        const q2 = new ParseQuery('Thing');
        return q2.find();
      })
      .then(results => {
        expect(results[0].get('name')).toBe('Name2');
        expect(results[0].has('other')).toBe(false);
      })
      .then(() => {
        expect(testObject.get('name')).toBe('Name2');
        expect(testObject.has('other')).toBe(false);
        done();
      });
  });

  it('does not override unselected fields with select query results', done => {
    jest.dontMock('../ParseObject');
    jest.resetModules();
    ParseObject = require('../ParseObject').default;
    CoreManager = require('../CoreManager');
    ParseQuery = require('../ParseQuery').default;

    ParseObject.enableSingleInstance();

    let objectToReturn = {
      objectId: 'T01',
      name: 'Name',
      other: 'other',
      tbd: 'exists',
      className: 'Thing',
      createdAt: '2017-01-10T10:00:00Z',
      subObject: { key1: 'value', key2: 'value2', key3: 'thisWillGoAway' },
    };

    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [objectToReturn],
        });
      },
    });

    const q = new ParseQuery('Thing');
    let testObject;
    return q
      .find()
      .then(results => {
        testObject = results[0];

        expect(testObject.get('name')).toBe('Name');
        expect(testObject.get('other')).toBe('other');
        expect(testObject.has('tbd')).toBe(true);
        expect(testObject.get('subObject').key1).toBe('value');
        expect(testObject.get('subObject').key2).toBe('value2');
        expect(testObject.get('subObject').key3).toBe('thisWillGoAway');

        const q2 = new ParseQuery('Thing');
        q2.select('other', 'tbd', 'subObject.key1', 'subObject.key3');
        objectToReturn = {
          objectId: 'T01',
          other: 'other2',
          subObject: { key1: 'updatedValue' },
        };
        return q2.find();
      })
      .then(results => {
        expect(results[0].get('name')).toBe('Name'); //query didn't select this
        expect(results[0].get('other')).toBe('other2'); //query selected and updated this
        expect(results[0].has('tbd')).toBe(false); //query selected this and it wasn't returned
        //sub-objects should work similarly
        expect(results[0].get('subObject').key1).toBe('updatedValue');
        expect(results[0].get('subObject').key2).toBe('value2');
        expect(results[0].get('subObject').key3).toBeUndefined();
      })
      .then(
        () => {
          expect(testObject.get('name')).toBe('Name');
          expect(testObject.get('other')).toBe('other2');
          expect(testObject.has('tbd')).toBe(false);
          expect(testObject.get('subObject').key1).toBe('updatedValue');
          expect(testObject.get('subObject').key2).toBe('value2');
          expect(testObject.get('subObject').key3).toBeUndefined();
          done();
        },
        error => {
          done.fail(error);
        }
      );
  });

  it('overrides cached object with first() results', done => {
    jest.dontMock('../ParseObject');
    jest.resetModules();
    ParseObject = require('../ParseObject').default;
    CoreManager = require('../CoreManager');
    ParseQuery = require('../ParseQuery').default;

    ParseObject.enableSingleInstance();

    let objectToReturn = {
      objectId: 'T01',
      name: 'Name',
      other: 'other',
      className: 'Thing',
      createdAt: '2017-01-10T10:00:00Z',
    };

    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [objectToReturn],
        });
      },
    });

    const q = new ParseQuery('Thing');
    let testObject;
    q.first()
      .then(result => {
        testObject = result;

        expect(testObject.get('name')).toBe('Name');
        expect(testObject.get('other')).toBe('other');

        objectToReturn = { objectId: 'T01', name: 'Name2' };
        const q2 = new ParseQuery('Thing');
        return q2.first();
      })
      .then(result => {
        expect(result.get('name')).toBe('Name2');
        expect(result.has('other')).toBe(false);
      })
      .then(() => {
        expect(testObject.get('name')).toBe('Name2');
        expect(testObject.has('other')).toBe(false);
        done();
      });
  });

  it('does not override unselected fields for first() on select query', done => {
    jest.dontMock('../ParseObject');
    jest.resetModules();
    ParseObject = require('../ParseObject').default;
    CoreManager = require('../CoreManager');
    ParseQuery = require('../ParseQuery').default;

    ParseObject.enableSingleInstance();

    let objectToReturn = {
      objectId: 'T01',
      name: 'Name',
      other: 'other',
      tbd: 'exists',
      className: 'Thing',
      subObject: { key1: 'value', key2: 'value2', key3: 'thisWillGoAway' },
      createdAt: '2017-01-10T10:00:00Z',
    };

    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [objectToReturn],
        });
      },
    });

    const q = new ParseQuery('Thing');
    let testObject;
    return q
      .first()
      .then(result => {
        testObject = result;

        expect(testObject.get('name')).toBe('Name');
        expect(testObject.get('other')).toBe('other');
        expect(testObject.has('tbd')).toBe(true);

        const q2 = new ParseQuery('Thing');
        q2.select('other', 'tbd', 'subObject.key1', 'subObject.key3');
        objectToReturn = {
          objectId: 'T01',
          other: 'other2',
          subObject: { key1: 'updatedValue' },
        };
        return q2.first();
      })
      .then(result => {
        expect(result.get('name')).toBe('Name'); //query didn't select this
        expect(result.get('other')).toBe('other2'); //query selected and updated this
        expect(result.has('tbd')).toBe(false); //query selected this and it wasn't returned
        //sub-objects should work similarly
        expect(result.get('subObject').key1).toBe('updatedValue');
        expect(result.get('subObject').key2).toBe('value2');
        expect(result.get('subObject').key3).toBeUndefined();
      })
      .then(
        () => {
          expect(testObject.get('name')).toBe('Name');
          expect(testObject.get('other')).toBe('other2');
          expect(testObject.has('tbd')).toBe(false);
          expect(testObject.get('subObject').key1).toBe('updatedValue');
          expect(testObject.get('subObject').key2).toBe('value2');
          expect(testObject.get('subObject').key3).toBeUndefined();
          done();
        },
        error => {
          done.fail(error);
        }
      );
  });

  it('restores queries from json representation', () => {
    const q = new ParseQuery('Item');

    q.include('manufacturer');
    q.select('inStock', 'lastPurchase');
    q.limit(10);
    q.withCount(true);
    q.ascending(['a', 'b', 'c']);
    q.skip(4);
    q.equalTo('size', 'medium');

    const json = q.toJSON();

    const newQuery = ParseQuery.fromJSON('Item', json);

    expect(newQuery.className).toBe('Item');

    expect(newQuery.toJSON()).toEqual({
      include: 'manufacturer',
      keys: 'inStock,lastPurchase',
      limit: 10,
      count: 1,
      order: 'a,b,c',
      skip: 4,
      where: {
        size: 'medium',
      },
    });
  });

  it('can issue a distinct query', done => {
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          distinct: 'size',
          where: {
            size: 'small',
          },
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: ['L'],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.equalTo('size', 'small')
      .distinct('size')
      .then(results => {
        expect(results[0]).toBe('L');
        done();
      });
  });

  it('can pass options to a distinct query', done => {
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          distinct: 'size',
          where: {
            size: 'small',
          },
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: ['L'],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.equalTo('size', 'small')
      .distinct('size', {
        sessionToken: '1234',
      })
      .then(results => {
        expect(results[0]).toBe('L');
        done();
      });
  });

  it('can pass options to a distinct query with hint', done => {
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          distinct: 'size',
          where: {
            size: 'small',
          },
          hint: '_id_',
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: ['L'],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.equalTo('size', 'small')
      .hint('_id_')
      .distinct('size', {
        sessionToken: '1234',
      })
      .then(results => {
        expect(results[0]).toBe('L');
        done();
      });
  });

  it('can issue a query to the controller', () => {
    const q = new ParseQuery('Item');
    q.readPreference('PRIMARY', 'SECONDARY', 'SECONDARY_PREFERRED');
    const json = q.toJSON();
    expect(json).toEqual({
      where: {},
      readPreference: 'PRIMARY',
      includeReadPreference: 'SECONDARY',
      subqueryReadPreference: 'SECONDARY_PREFERRED',
    });
    const query = ParseQuery.fromJSON('Item', json);
    expect(query._readPreference).toBe('PRIMARY');
    expect(query._includeReadPreference).toBe('SECONDARY');
    expect(query._subqueryReadPreference).toBe('SECONDARY_PREFERRED');
  });

  it('can issue an aggregate query with array pipeline', done => {
    const pipeline = [{ group: { objectId: '$name' } }];
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params.pipeline).toEqual([{ group: { objectId: '$name' } }]);
        expect(options.useMasterKey).toEqual(true);
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.aggregate(pipeline).then(results => {
      expect(results).toEqual([]);
      done();
    });
  });

  it('aggregate query array pipeline with equalTo', done => {
    const pipeline = [{ group: { objectId: '$name' } }];
    MockRESTController.request.mockImplementationOnce(() => {
      return Promise.resolve({
        results: [],
      });
    });
    const q = new ParseQuery('Item');
    q.equalTo('name', 'foo');
    q.aggregate(pipeline).then(results => {
      expect(results).toEqual([]);
      done();
    });
  });

  it('aggregate query object pipeline with equalTo', done => {
    const pipeline = {
      group: { objectId: '$name' },
    };
    MockRESTController.request.mockImplementationOnce(() => {
      return Promise.resolve({
        results: [],
      });
    });
    const q = new ParseQuery('Item');
    q.equalTo('name', 'foo');
    q.aggregate(pipeline).then(results => {
      expect(results).toEqual([]);
      done();
    });
  });

  it('can issue an aggregate query with object pipeline', done => {
    const pipeline = {
      group: { objectId: '$name' },
    };
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params.pipeline).toEqual({ group: { objectId: '$name' } });
        expect(options.useMasterKey).toEqual(true);
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.aggregate(pipeline).then(results => {
      expect(results).toEqual([]);
      done();
    });
  });

  it('cannot issue an aggregate query with invalid pipeline', done => {
    const pipeline = 1234;
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params).toEqual({
          group: { objectId: '$name' },
        });
        expect(options.useMasterKey).toEqual(true);
        expect(options.requestTask).toBeDefined();
        return Promise.resolve({
          results: [],
        });
      },
    });

    try {
      const q = new ParseQuery('Item');
      q.aggregate(pipeline).then(() => {});
    } catch (e) {
      done();
    }
  });

  it('can pass options to an aggregate query', done => {
    const pipeline = [{ group: { objectId: '$name' } }];
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params.pipeline).toEqual([{ group: { objectId: '$name' } }]);
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        return Promise.resolve({
          results: [],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.aggregate(pipeline, {
      sessionToken: '1234',
    }).then(results => {
      expect(results).toEqual([]);
      done();
    });
  });

  it('can issue an aggregate query with read preference', async () => {
    // Override controller
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params.readPreference).toEqual('SECONDARY');
        expect(options.useMasterKey).toEqual(true);
        return Promise.resolve({
          results: [],
        });
      },
    });
    // Query
    const q = new ParseQuery('Item');
    q.readPreference('SECONDARY');
    const results = await q.aggregate([], { sessionToken: '1234' });
    // Validate
    expect(results).toEqual([]);
  });

  it('can pass options to an aggregate query with hint', done => {
    const pipeline = [{ group: { objectId: '$name' } }];
    CoreManager.setQueryController({
      find() {},
      aggregate(className, params, options) {
        expect(className).toBe('Item');
        expect(params.pipeline).toEqual([{ group: { objectId: '$name' } }]);
        expect(params.hint).toEqual('_id_');
        expect(options.useMasterKey).toEqual(true);
        expect(options.sessionToken).toEqual('1234');
        return Promise.resolve({
          results: [],
        });
      },
    });

    const q = new ParseQuery('Item');
    q.hint('_id_')
      .aggregate(pipeline, {
        sessionToken: '1234',
      })
      .then(results => {
        expect(results).toEqual([]);
        done();
      });
  });

  it('can cancel query', async () => {
    const mockRequestTask = {
      abort: () => {},
    };

    CoreManager.setQueryController({
      find: function (name, params, options) {
        options.requestTask(mockRequestTask);
        return Promise.resolve({
          results: [],
        });
      },
      aggregate: () => {},
    });
    const query = new ParseQuery('TestCancel');
    expect(query._xhrRequest).toBeDefined();
    expect(query._xhrRequest.task).toBe(null);
    expect(query._xhrRequest.onchange()).toBeUndefined();

    jest.spyOn(mockRequestTask, 'abort');
    query.cancel();
    expect(mockRequestTask.abort).toHaveBeenCalledTimes(0);

    await query.find();

    expect(query._xhrRequest.task).toEqual(null);
    query.cancel();
    expect(mockRequestTask.abort).toHaveBeenCalledTimes(1);
  });

  it('selecting sub-objects does not inject objects when sub-object does not exist', done => {
    jest.dontMock('../ParseObject');
    jest.resetModules();
    ParseObject = require('../ParseObject').default;
    CoreManager = require('../CoreManager');
    ParseQuery = require('../ParseQuery').default;

    ParseObject.enableSingleInstance();

    const objectToReturn = {
      objectId: 'T01',
      name: 'Name',
      tbd: 'exists',
      className: 'Thing',
      createdAt: '2017-01-10T10:00:00Z',
    };

    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [objectToReturn],
        });
      },
    });

    const q = new ParseQuery('Thing');
    q.select('other', 'tbd', 'subObject.key1');
    let testObject;
    return q
      .find()
      .then(results => {
        testObject = results[0];

        expect(testObject.get('name')).toBe('Name');
        expect(testObject.has('other')).toBe(false);
        expect(testObject.has('subObject')).toBe(false);
      })
      .then(
        () => {
          done();
        },
        error => {
          done.fail(error);
        }
      );
  });

  it('removes missing sub objects from the cached object when they are selected', done => {
    jest.dontMock('../ParseObject');
    jest.resetModules();
    ParseObject = require('../ParseObject').default;
    CoreManager = require('../CoreManager');
    ParseQuery = require('../ParseQuery').default;

    ParseObject.enableSingleInstance();

    let objectToReturn = {
      objectId: 'T01',
      name: 'Name',
      tbd: 'exists',
      className: 'Thing',
      subObject1: { foo: 'bar' },
      subObject2: { foo: 'bar' },
      subObject3: { foo: 'bar' },
      subObject5: { subSubObject: { foo: 'foo', bar: 'bar' } },
      createdAt: '2017-01-10T10:00:00Z',
    };

    CoreManager.setQueryController({
      aggregate() {},
      find() {
        return Promise.resolve({
          results: [objectToReturn],
        });
      },
    });

    const q = new ParseQuery('Thing');
    let testObject;
    return q
      .find()
      .then(results => {
        testObject = results[0];

        expect(testObject.has('subObject1')).toBe(true);
        expect(testObject.has('subObject2')).toBe(true);
        expect(testObject.has('subObject3')).toBe(true);
        expect(testObject.has('subObject4')).toBe(false);

        const q2 = new ParseQuery('Thing');
        q2.select(
          'name',
          'subObject1',
          'subObject2.foo',
          'subObject4.foo',
          'subObject5.subSubObject.foo'
        );
        objectToReturn = {
          objectId: 'T01',
          name: 'Name',
          subObject4: { foo: 'bar' },
          subObject5: { subSubObject: {} },
        };
        return q2.find();
      })
      .then(() => {
        expect(testObject.has('subObject1')).toBe(false); //selected and not returned
        expect(testObject.has('subObject2')).toBe(false); //selected and not returned
        expect(testObject.has('subObject3')).toBe(true); //not selected, so should still be there
        expect(testObject.has('subObject4')).toBe(true); //selected and just added
        expect(testObject.has('subObject5')).toBe(true);
        expect(testObject.get('subObject5').subSubObject).toBeDefined();
        expect(testObject.get('subObject5').subSubObject.bar).toBeDefined(); //not selected but a sibiling was, so should still be there
      })
      .then(
        () => {
          done();
        },
        error => {
          done.fail(error);
        }
      );
  });

  it('full text search', () => {
    const query = new ParseQuery('Item');
    query.fullText('size', 'small');

    expect(query.toJSON()).toEqual({
      where: {
        size: {
          $text: {
            $search: {
              $term: 'small',
            },
          },
        },
      },
    });
  });

  it('full text search sort', () => {
    const query = new ParseQuery('Item');
    query.fullText('size', 'medium');
    query.ascending('$score');
    query.select('$score');

    expect(query.toJSON()).toEqual({
      where: {
        size: {
          $text: {
            $search: {
              $term: 'medium',
            },
          },
        },
      },
      keys: '$score',
      order: '$score',
    });
  });

  it('full text search key required', done => {
    const query = new ParseQuery('Item');
    expect(() => query.fullText()).toThrow('A key is required.');
    done();
  });

  it('full text search value required', done => {
    const query = new ParseQuery('Item');
    expect(() => query.fullText('key')).toThrow('A search term is required');
    done();
  });

  it('full text search value must be string', done => {
    const query = new ParseQuery('Item');
    expect(() => query.fullText('key', [])).toThrow(
      'The value being searched for must be a string.'
    );
    done();
  });

  it('full text search invalid option', done => {
    const query = new ParseQuery('Item');
    expect(() => query.fullText('size', 'medium', { unknown: 'throwOption' })).toThrow(
      'Unknown option: unknown'
    );
    done();
  });

  it('full text search with all parameters', () => {
    const query = new ParseQuery('Item');

    query.fullText('size', 'medium', {
      language: 'en',
      caseSensitive: false,
      diacriticSensitive: true,
    });

    expect(query.toJSON()).toEqual({
      where: {
        size: {
          $text: {
            $search: {
              $term: 'medium',
              $language: 'en',
              $caseSensitive: false,
              $diacriticSensitive: true,
            },
          },
        },
      },
    });
  });

  it('add the score for the full text search', () => {
    const query = new ParseQuery('Item');

    query.fullText('size', 'medium', { language: 'fr' });
    query.sortByTextScore();

    expect(query.toJSON()).toEqual({
      where: {
        size: {
          $text: {
            $search: {
              $term: 'medium',
              $language: 'fr',
            },
          },
        },
      },
      keys: '$score',
      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(DEFAULT_PIN);
    const query = q.fromNetwork();
    expect(q._queriesLocalDatastore).toBe(false);
    expect(q._localDatastorePinName).toBe(null);
    expect(query).toEqual(q);
  });

  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 with localId', async () => {
    const obj1 = {
      className: 'Item',
      _localId: 'local0',
      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]._localId).toEqual(obj1._localId);
  });

  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'),
    };

    const obj4 = {
      className: 'Item',
      objectId: 'objectId4',
      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,
      obj3,
      obj2,
      obj4,
    ]);

    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);
    expect(results[3].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(4);
    expect(results[2].get('number')).toEqual(3);
    expect(results[3].get('number')).toEqual(2);

    q = new ParseQuery('Item');
    q.ascending('_created_at');
    q.fromLocalDatastore();
    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.ascending('_updated_at');
    q.fromLocalDatastore();
    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('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 withCount, skip and limit', 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.withCount(true);
    q.fromLocalDatastore();
    let result = await q.find();
    expect(result.results.length).toEqual(3);
    expect(result.count).toEqual(3);

    q = new ParseQuery('Item');
    q.skip(1);
    q.withCount(true);
    q.fromLocalDatastore();
    result = await q.find();
    expect(result.results.length).toEqual(2);
    expect(result.count).toEqual(3);

    q = new ParseQuery('Item');
    q.skip(3);
    q.withCount(true);
    q.fromLocalDatastore();
    result = await q.find();
    expect(result.results.length).toEqual(0);
    expect(result.count).toEqual(3);

    q = new ParseQuery('Item');
    q.withCount(true);
    q.skip(4);
    q.fromLocalDatastore();
    result = await q.find();
    expect(result.results.length).toEqual(0);
    expect(result.count).toEqual(3);

    q = new ParseQuery('Item');
    q.limit(1);
    q.skip(2);
    q.withCount(true);
    q.fromLocalDatastore();
    result = await q.find();
    expect(result.results.length).toEqual(1);
    expect(result.count).toEqual(3);

    q = new ParseQuery('Item');
    q.limit(1);
    q.skip(1);
    q.withCount(true);
    q.fromLocalDatastore();
    result = await q.find();
    expect(result.results.length).toEqual(1);
    expect(result.count).toEqual(3);

    q = new ParseQuery('Item');
    q.limit(2);
    q.skip(1);
    q.withCount(true);
    q.fromLocalDatastore();
    result = await q.find();
    expect(result.results.length).toEqual(2);
    expect(result.count).toEqual(3);
  });

  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');
  });

  it('can subscribe to query if client is already open', async () => {
    const mockLiveQueryClient = {
      shouldOpen: function () {
        return false;
      },
      subscribe: function (query, sessionToken) {
        const subscription = new LiveQuerySubscription('0', query, sessionToken);
        subscription.subscribePromise.resolve();
        return subscription;
      },
    };
    CoreManager.set('UserController', {
      currentUserAsync() {
        return Promise.resolve({
          getSessionToken() {
            return 'token';
          },
        });
      },
    });
    CoreManager.set('LiveQueryController', {
      getDefaultLiveQueryClient() {
        return Promise.resolve(mockLiveQueryClient);
      },
    });
    const query = new ParseQuery('TestObject');
    const subscription = await query.subscribe();
    expect(subscription.id).toBe('0');
    expect(subscription.sessionToken).toBe('token');
    expect(subscription.query).toEqual(query);
  });

  it('can subscribe to query if client is not open', async () => {
    const mockLiveQueryClient = {
      shouldOpen: function () {
        return true;
      },
      open: function () {},
      subscribe: function (query, sessionToken) {
        const subscription = new LiveQuerySubscription('0', query, sessionToken);
        subscription.subscribePromise.resolve();
        return subscription;
      },
    };
    CoreManager.set('UserController', {
      currentUserAsync() {
        return Promise.resolve({
          getSessionToken() {
            return 'token';
          },
        });
      },
    });
    CoreManager.set('LiveQueryController', {
      getDefaultLiveQueryClient() {
        return Promise.resolve(mockLiveQueryClient);
      },
    });
    const query = new ParseQuery('TestObject');
    const subscription = await query.subscribe();
    expect(subscription.id).toBe('0');
    expect(subscription.sessionToken).toBe('token');
    expect(subscription.query).toEqual(query);
  });

  it('can subscribe to query without sessionToken', async () => {
    const mockLiveQueryClient = {
      shouldOpen: function () {
        return true;
      },
      open: function () {},
      subscribe: function (query, sessionToken) {
        const subscription = new LiveQuerySubscription('0', query, sessionToken);
        subscription.subscribePromise.resolve();
        return subscription;
      },
    };
    CoreManager.set('UserController', {
      currentUserAsync() {
        return Promise.resolve(null);
      },
    });
    CoreManager.set('LiveQueryController', {
      getDefaultLiveQueryClient() {
        return Promise.resolve(mockLiveQueryClient);
      },
    });
    const query = new ParseQuery('TestObject');
    const subscription = await query.subscribe();
    expect(subscription.id).toBe('0');
    expect(subscription.sessionToken).toBeUndefined();
    expect(subscription.query).toEqual(query);
  });

  it('can subscribe to query with sessionToken parameter', async () => {
    const mockLiveQueryClient = {
      shouldOpen: function () {
        return true;
      },
      open: function () {},
      subscribe: function (query, sessionToken) {
        const subscription = new LiveQuerySubscription('0', query, sessionToken);
        subscription.subscribePromise.resolve();
        return subscription;
      },
    };
    CoreManager.set('UserController', {
      currentUserAsync() {
        return Promise.resolve(null);
      },
    });
    CoreManager.set('LiveQueryController', {
      getDefaultLiveQueryClient() {
        return Promise.resolve(mockLiveQueryClient);
      },
    });
    const query = new ParseQuery('TestObject');
    const subscription = await query.subscribe('r:test');
    expect(subscription.id).toBe('0');
    expect(subscription.sessionToken).toBe('r:test');
    expect(subscription.query).toEqual(query);
  });
});