kinvey-flex-sdk
Version:
SDK for creating Kinvey Flex Services
551 lines (436 loc) • 14.5 kB
JavaScript
/**
* Copyright (c) 2018 Kinvey Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under the License
* is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express
* or implied. See the License for the specific language governing permissions and limitations under
* the License.
*/
const util = require('util');
const sift = require('sift');
const isArray = Array.isArray;
const isRegExp = util.isRegExp;
const PROTECTED_FIELDS = ['_id', '_acl'];
class QueryError extends Error {
constructor(debug, description = 'An error occurred on the query.') {
super('QueryError');
this.description = description;
this.debug = debug;
}
}
function isObjectEmpty(obj) {
return Object.keys(obj).length === 0;
}
function nested(nestedObj, dotProperty, value) {
let obj = nestedObj;
if (!dotProperty) {
return value || obj;
}
const parts = dotProperty.split('.');
let current = parts.shift();
while (current && obj) {
obj = obj[current];
current = parts.shift();
}
return value || obj;
}
module.exports = class Query {
constructor(options = {}) {
const queryOptions = Object.assign({
fields: [],
filter: {},
sort: {},
limit: null,
skip: 0
}, options);
this.fields = queryOptions.fields;
this.filter = queryOptions.filter;
this.sort = queryOptions.sort;
this.limit = queryOptions.limit;
this.skip = queryOptions.skip;
this._parent = null;
}
get fields() {
return this._fields;
}
set fields(fields = []) {
if (!Array.isArray(fields)) {
throw new QueryError('fields must be an Array');
}
if (this._parent) {
this._parent.fields = fields;
} else {
this._fields = fields;
}
}
get filter() {
return this._filter;
}
set filter(filter) {
this._filter = filter;
}
get sort() {
return this._sort;
}
set sort(sort) {
if (sort && typeof sort !== 'object') {
throw new QueryError('sort must an Object');
}
if (this._parent) {
this._parent.sort = sort;
} else {
this._sort = sort || {};
}
}
get limit() {
return this._limit;
}
set limit(limit) {
const queryLimit = typeof limit === 'string' ? parseFloat(limit) : limit;
if (queryLimit && typeof queryLimit !== 'number') {
throw new QueryError('limit must be a number');
}
if (this._parent) {
this._parent.limit = queryLimit;
} else {
this._limit = queryLimit;
}
}
get skip() {
return this._skip;
}
set skip(skip = 0) {
const querySkip = typeof skip === 'string' ? parseFloat(skip) : skip;
if (typeof querySkip !== 'number') {
throw new QueryError('skip must be a number');
}
if (this._parent) {
this._parent.skip = querySkip;
} else {
this._skip = querySkip;
}
}
equalTo(field, value) {
return this.addFilter(field, value);
}
contains(field, values) {
const containsValues = isArray(values) ? values : [values];
return this.addFilter(field, '$in', containsValues);
}
containsAll(field, values) {
const containsValues = isArray(values) ? values : [values];
return this.addFilter(field, '$all', containsValues);
}
greaterThan(field, value) {
if (typeof value !== 'number' && typeof value !== 'string') {
throw new QueryError('You must supply a number or string.');
}
return this.addFilter(field, '$gt', value);
}
greaterThanOrEqualTo(field, value) {
if (typeof value !== 'number' && typeof value !== 'string') {
throw new QueryError('You must supply a number or string.');
}
return this.addFilter(field, '$gte', value);
}
lessThan(field, value) {
if (typeof value !== 'number' && typeof value !== 'string') {
throw new QueryError('You must supply a number or string.');
}
return this.addFilter(field, '$lt', value);
}
lessThanOrEqualTo(field, value) {
if (typeof value !== 'number' && typeof value !== 'string') {
throw new QueryError('You must supply a number or string.');
}
return this.addFilter(field, '$lte', value);
}
notEqualTo(field, value) {
return this.addFilter(field, '$ne', value);
}
notContainedIn(field, values) {
const containsValues = isArray(values) ? values : [values];
return this.addFilter(field, '$nin', containsValues);
}
and(...args) {
// AND has highest precedence. Therefore, even if this query is part of a
// JOIN already, apply it on this query.
return this.join('$and', args);
}
nor(...args) {
// NOR is preceded by AND. Therefore, if this query is part of an AND-join,
// apply the NOR onto the parent to make sure AND indeed precedes NOR.
if (this._parent && this._parent.filter.$and) {
return this._parent.nor(...args);
}
return this.join('$nor', args);
}
or(...args) {
// OR has lowest precedence. Therefore, if this query is part of any join,
// apply the OR onto the parent to make sure OR has indeed the lowest
// precedence.
if (this._parent) {
return this._parent.or(...args);
}
return this.join('$or', args);
}
exists(field, flag) {
const fieldExists = typeof flag === 'undefined' ? true : flag || false;
return this.addFilter(field, '$exists', fieldExists);
}
mod(field, divisor, remainder = 0) {
const modDivisor = typeof divisor === 'string' ? parseFloat(divisor) : divisor;
const modRemainder = typeof remainder === 'string' ? parseFloat(remainder) : remainder;
if (typeof divisor !== 'number') {
throw new QueryError('divisor must be a number');
}
if (typeof remainder !== 'number') {
throw new QueryError('remainder must be a number');
}
return this.addFilter(field, '$mod', [modDivisor, modRemainder]);
}
matches(field, regExp, options = {}) {
const expression = isRegExp(regExp) ? regExp : new RegExp(regExp);
if ((expression.ignoreCase || options.ignoreCase) && options.ignoreCase !== false) {
throw new QueryError('ignoreCase is not supported.');
}
if (expression.source.indexOf('^') !== 0) {
throw new QueryError('regExp must have `^` at the beginning of the expression ' +
'to make it an anchored expression.');
}
const flags = [];
if ((expression.multiline || options.multiline) && options.multiline !== false) {
flags.push('m');
}
if (options.extended) {
flags.push('x');
}
if (options.dotMatchesAll) {
flags.push('s');
}
const result = this.addFilter(field, '$regex', expression.source);
if (flags.length) {
this.addFilter(field, '$options', flags.join(''));
}
return result;
}
near(field, coord, maxDistance) {
if (!isArray(coord) || typeof coord[0] !== 'number' || typeof coord[1] !== 'number') {
throw new QueryError('coord must be a [number, number]');
}
const result = this.addFilter(field, '$nearSphere', [coord[0], coord[1]]);
if (maxDistance) {
this.addFilter(field, '$maxDistance', maxDistance);
}
return result;
}
withinBox(field, bottomLeftCoord, upperRightCoord) {
if (!isArray(bottomLeftCoord) || !bottomLeftCoord[0] || !bottomLeftCoord[1]) {
throw new QueryError('bottomLeftCoord must be a [number, number]');
}
if (!isArray(upperRightCoord) || !upperRightCoord[0] || !upperRightCoord[1]) {
throw new QueryError('upperRightCoord must be a [number, number]');
}
bottomLeftCoord[0] = parseFloat(bottomLeftCoord[0]);
bottomLeftCoord[1] = parseFloat(bottomLeftCoord[1]);
upperRightCoord[0] = parseFloat(upperRightCoord[0]);
upperRightCoord[1] = parseFloat(upperRightCoord[1]);
const coords = [
[bottomLeftCoord[0], bottomLeftCoord[1]],
[upperRightCoord[0], upperRightCoord[1]]
];
return this.addFilter(field, '$within', { $box: coords });
}
withinPolygon(field, coords) {
if (!isArray(coords) || coords.length === 0 || coords.length > 3) {
throw new QueryError('coords must be [[number, number]]');
}
const polyCoords = coords.map((coord) => {
if (!coord[0] || !coord[1]) {
throw new QueryError('coords argument must be [number, number]');
}
return [parseFloat(coord[0]), parseFloat(coord[1])];
});
return this.addFilter(field, '$within', { $polygon: polyCoords });
}
size(field, size) {
const sizeValue = typeof size === 'string' ? parseFloat(size) : size;
if (typeof sizeValue !== 'number') {
throw new QueryError('size must be a number');
}
return this.addFilter(field, '$size', sizeValue);
}
ascending(field) {
if (this._parent) {
this._parent.ascending(field);
} else {
this.sort[field] = 1;
}
return this;
}
descending(field) {
if (this._parent) {
this._parent.descending(field);
} else {
this.sort[field] = -1;
}
return this;
}
addFilter(field, condition, values) {
if (typeof this.filter[field] !== 'object') {
this.filter[field] = {};
}
const isValueValid = values || values === 0 || values === null || values === '' || values === false ||
(typeof values === 'number' && isNaN(values));
if (condition && isValueValid) {
this.filter[field][condition] = values;
} else {
this.filter[field] = condition;
}
return this;
}
join(operator, queries) {
let that = this;
const currentQuery = {};
// Cast, validate, and parse arguments. If `queries` are supplied, obtain
// the `filter` for joining. The eventual return function will be the
// current query.
let mappedQueries = queries.map((query) => {
if (!(query instanceof Query)) {
if (typeof query === 'object') {
return new Query(query).toJSON().filter;
}
throw new QueryError('query argument must be of type: Kinvey.Query[] or Object[].');
}
return query.toJSON().filter;
});
// If there are no `queries` supplied, create a new (empty) `Query`.
// This query is the right-hand side of the join expression, and will be
// returned to allow for a fluent interface.
if (mappedQueries.length === 0) {
that = new Query();
mappedQueries = [that.toJSON().filter];
that._parent = this; // Required for operator precedence and `toJSON`.
}
// Join operators operate on the top-level of `filter`. Since the `toJSON`
// magic requires `filter` to be passed by reference, we cannot simply re-
// assign `filter`. Instead, empty it without losing the reference.
const members = Object.keys(this.filter);
members.forEach((member) => {
currentQuery[member] = this.filter[member];
delete this.filter[member];
});
// `currentQuery` is the left-hand side query. Join with `queries`.
this.filter[operator] = [currentQuery].concat(mappedQueries);
// Return the current query if there are `queries`, and the new (empty)
// `PrivateQuery` otherwise.
return that;
}
process(dataToProcess) {
let data = dataToProcess;
if (data) {
// Validate arguments.
if (!isArray(data)) {
throw new QueryError('data argument must be of type: Array.');
}
// Apply the query
const json = this.toJSON();
data = sift(json.filter, data);
if (json.sort != null) {
data.sort((a, b) => {
let sortAction = 0;
Object.keys(json.sort).some((field) => {
// Find field in objects.
const aField = nested(a, field);
const bField = nested(b, field);
const modifier = json.sort[field]; // 1 (ascending) or -1 (descending).
if (aField != null && bField == null) {
sortAction = 1 * modifier;
return true;
} else if (aField == null && bField != null) {
sortAction = -1 * modifier;
return true;
} else if (typeof aField === 'undefined' && bField === null) {
sortAction = 0;
return true;
} else if (aField === null && typeof bField === 'undefined') {
sortAction = 0;
return true;
} else if (aField !== bField) {
sortAction = (aField < bField ? -1 : 1) * modifier;
}
return false;
});
return sortAction;
});
}
// Remove fields
if (Array.isArray(json.fields) && json.fields.length > 0) {
const fields = [].concat(json.fields, PROTECTED_FIELDS);
data = data.map((item) => {
const keys = Object.keys(item);
keys.forEach((key) => {
if (fields.indexOf(key) === -1) {
delete item[key];
}
});
return item;
});
}
// Limit and skip.
if (json.limit) {
return data.slice(json.skip, json.skip + json.limit);
}
return data.slice(json.skip);
}
return data;
}
toPlainObject() {
if (this._parent) {
return this._parent.toPlainObject();
}
// Return set of parameters.
const json = {
fields: this.fields,
filter: this.filter,
sort: this.sort,
skip: this.skip,
limit: this.limit
};
return json;
}
toJSON() {
return this.toPlainObject();
}
toQueryString() {
const queryString = {};
if (!isObjectEmpty(this.filter)) {
queryString.query = this.filter;
}
if (this.fields.length > 0) {
queryString.fields = this.fields.join(',');
}
if (this.limit) {
queryString.limit = this.limit;
}
if (this.skip > 0) {
queryString.skip = this.skip;
}
if (!isObjectEmpty(this.sort)) {
queryString.sort = this.sort;
}
const keys = Object.keys(queryString);
keys.forEach((key) => {
queryString[key] = typeof queryString[key] === 'string' ? queryString[key] : JSON.stringify(queryString[key]);
});
return queryString;
}
toString() {
return JSON.stringify(this.toQueryString());
}
};