pouchdb-find
Version:
Easy-to-use query language for PouchDB
1,533 lines (1,317 loc) • 43.4 kB
JavaScript
;
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var pouchdbErrors = require('pouchdb-errors');
var pouchdbFetch = require('pouchdb-fetch');
var abstractMapReduce = _interopDefault(require('pouchdb-abstract-mapreduce'));
var pouchdbMd5 = require('pouchdb-md5');
var pouchdbCollate = require('pouchdb-collate');
var pouchdbSelectorCore = require('pouchdb-selector-core');
var pouchdbUtils = require('pouchdb-utils');
const nativeFlat = (...args) => args.flat(Infinity);
const polyFlat = (...args) => {
let res = [];
for (const subArr of args) {
if (Array.isArray(subArr)) {
res = res.concat(polyFlat(...subArr));
} else {
res.push(subArr);
}
}
return res;
};
const flatten = typeof Array.prototype.flat === 'function'
? nativeFlat
: polyFlat;
function mergeObjects(arr) {
const res = {};
for (const element of arr) {
Object.assign(res, element);
}
return res;
}
// Selects a list of fields defined in dot notation from one doc
// and copies them to a new doc. Like underscore _.pick but supports nesting.
function pick(obj, arr) {
const res = {};
for (const field of arr) {
const parsedField = pouchdbSelectorCore.parseField(field);
const value = pouchdbSelectorCore.getFieldFromDoc(obj, parsedField);
if (typeof value !== 'undefined') {
pouchdbSelectorCore.setFieldInDoc(res, parsedField, value);
}
}
return res;
}
// e.g. ['a'], ['a', 'b'] is true, but ['b'], ['a', 'b'] is false
function oneArrayIsSubArrayOfOther(left, right) {
for (let i = 0, len = Math.min(left.length, right.length); i < len; i++) {
if (left[i] !== right[i]) {
return false;
}
}
return true;
}
// e.g.['a', 'b', 'c'], ['a', 'b'] is false
function oneArrayIsStrictSubArrayOfOther(left, right) {
if (left.length > right.length) {
return false;
}
return oneArrayIsSubArrayOfOther(left, right);
}
// same as above, but treat the left array as an unordered set
// e.g. ['b', 'a'], ['a', 'b', 'c'] is true, but ['c'], ['a', 'b', 'c'] is false
function oneSetIsSubArrayOfOther(left, right) {
left = left.slice();
for (const field of right) {
if (!left.length) {
break;
}
const leftIdx = left.indexOf(field);
if (leftIdx === -1) {
return false;
} else {
left.splice(leftIdx, 1);
}
}
return true;
}
function arrayToObject(arr) {
const res = {};
for (const field of arr) {
res[field] = true;
}
return res;
}
function max(arr, fun) {
let max = null;
let maxScore = -1;
for (const element of arr) {
const score = fun(element);
if (score > maxScore) {
maxScore = score;
max = element;
}
}
return max;
}
function arrayEquals(arr1, arr2) {
if (arr1.length !== arr2.length) {
return false;
}
for (let i = 0, len = arr1.length; i < len; i++) {
if (arr1[i] !== arr2[i]) {
return false;
}
}
return true;
}
function uniq(arr) {
return Array.from(new Set(arr));
}
/**
* Callbackifyable wrapper for async functions
*
* @template T, Args
* @param {(...args: Args) => Promise<T>} fun
* @returns {<CBArgs = [...Args, (err: any, value: T) => void], InnerArgs extends Args | CBArgs>(...innerArgs: InnerArgs) => InnerArgs extends CBArgs ? void : Promise<T>}
*
* @example
* const fn = resolveToCallback(async () => { return 42; })
* // with callback:
* fn((err, value) => { ... })
* // with await:
* const value = await fn()
*/
function resolveToCallback(fun) {
return function (...args) {
const maybeCallback = args[args.length - 1];
if (typeof maybeCallback === "function") {
const fulfilled = maybeCallback.bind(null, null);
const rejected = maybeCallback.bind(null);
fun.apply(this, args.slice(0, -1)).then(fulfilled, rejected);
} else {
return fun.apply(this, args);
}
};
}
// we restructure the supplied JSON considerably, because the official
// Mango API is very particular about a lot of this stuff, but we like
// to be liberal with what we accept in order to prevent mental
// breakdowns in our users
function massageCreateIndexRequest(requestDef) {
requestDef = pouchdbUtils.clone(requestDef);
if (!requestDef.index) {
requestDef.index = {};
}
for (const key of ['type', 'name', 'ddoc']) {
if (requestDef.index[key]) {
requestDef[key] = requestDef.index[key];
delete requestDef.index[key];
}
}
if (requestDef.fields) {
requestDef.index.fields = requestDef.fields;
delete requestDef.fields;
}
if (!requestDef.type) {
requestDef.type = 'json';
}
return requestDef;
}
function isNonNullObject(value) {
return typeof value === 'object' && value !== null;
}
// throws if the user is using the wrong query field value type
function checkFieldValueType(name, value, isHttp) {
let message = '';
let received = value;
let addReceived = true;
if (['$in', '$nin', '$or', '$and', '$mod', '$nor', '$all'].indexOf(name) !== -1) {
if (!Array.isArray(value)) {
message = 'Query operator ' + name + ' must be an array.';
}
}
if (['$not', '$elemMatch', '$allMatch'].indexOf(name) !== -1) {
if (!(!Array.isArray(value) && isNonNullObject(value))) {
message = 'Query operator ' + name + ' must be an object.';
}
}
if (name === '$mod' && Array.isArray(value)) {
if (value.length !== 2) {
message = 'Query operator $mod must be in the format [divisor, remainder], ' +
'where divisor and remainder are both integers.';
} else {
const divisor = value[0];
const mod = value[1];
if (divisor === 0) {
message = 'Query operator $mod\'s divisor cannot be 0, cannot divide by zero.';
addReceived = false;
}
if (typeof divisor !== 'number' || parseInt(divisor, 10) !== divisor) {
message = 'Query operator $mod\'s divisor is not an integer.';
received = divisor;
}
if (parseInt(mod, 10) !== mod) {
message = 'Query operator $mod\'s remainder is not an integer.';
received = mod;
}
}
}
if (name === '$exists') {
if (typeof value !== 'boolean') {
message = 'Query operator $exists must be a boolean.';
}
}
if (name === '$type') {
const allowed = ['null', 'boolean', 'number', 'string', 'array', 'object'];
const allowedStr = '"' + allowed.slice(0, allowed.length - 1).join('", "') + '", or "' + allowed[allowed.length - 1] + '"';
if (typeof value !== 'string') {
message = 'Query operator $type must be a string. Supported values: ' + allowedStr + '.';
} else if (allowed.indexOf(value) == -1) {
message = 'Query operator $type must be a string. Supported values: ' + allowedStr + '.';
}
}
if (name === '$size') {
if (parseInt(value, 10) !== value) {
message = 'Query operator $size must be a integer.';
}
}
if (name === '$regex') {
if (typeof value !== 'string') {
if (isHttp) {
message = 'Query operator $regex must be a string.';
} else if (!(value instanceof RegExp)) {
message = 'Query operator $regex must be a string or an instance ' +
'of a javascript regular expression.';
}
}
}
if (message) {
if (addReceived) {
const type = received === null
? ' '
: Array.isArray(received)
? ' array'
: ' ' + typeof received;
const receivedStr = isNonNullObject(received)
? JSON.stringify(received, null, '\t')
: received;
message += ' Received' + type + ': ' + receivedStr;
}
throw new Error(message);
}
}
const requireValidation = ['$all', '$allMatch', '$and', '$elemMatch', '$exists', '$in', '$mod', '$nin', '$nor', '$not', '$or', '$regex', '$size', '$type'];
const arrayTypeComparisonOperators = ['$in', '$nin', '$mod', '$all'];
const equalityOperators = ['$eq', '$gt', '$gte', '$lt', '$lte'];
// recursively walks down the a query selector validating any operators
function validateSelector(input, isHttp) {
if (Array.isArray(input)) {
for (const entry of input) {
if (isNonNullObject(entry)) {
validateSelector(entry, isHttp);
}
}
}
else {
for (const [key, value] of Object.entries(input)) {
if (requireValidation.indexOf(key) !== -1) {
checkFieldValueType(key, value, isHttp);
}
if (equalityOperators.indexOf(key) !== -1) {
// skip, explicit comparison operators can be anything
continue;
}
if (arrayTypeComparisonOperators.indexOf(key) !== -1) {
// skip, their values are already valid
continue;
}
if (isNonNullObject(value)) {
validateSelector(value, isHttp);
}
}
}
}
async function dbFetch(db, path, opts) {
if (opts.body) {
opts.body = JSON.stringify(opts.body);
opts.headers = new pouchdbFetch.Headers({ 'Content-type': 'application/json' });
}
const response = await db.fetch(path, opts);
const json = await response.json();
if (!response.ok) {
json.status = response.status;
const pouchError = pouchdbErrors.createError(json);
throw pouchdbErrors.generateErrorFromResponse(pouchError);
}
return json;
}
async function createIndex(db, requestDef) {
return await dbFetch(db, '_index', {
method: 'POST',
body: massageCreateIndexRequest(requestDef)
});
}
async function find(db, requestDef) {
validateSelector(requestDef.selector, true);
return await dbFetch(db, '_find', {
method: 'POST',
body: requestDef
});
}
async function explain(db, requestDef) {
return await dbFetch(db, '_explain', {
method: 'POST',
body: requestDef
});
}
async function getIndexes(db) {
return await dbFetch(db, '_index', {
method: 'GET'
});
}
async function deleteIndex(db, indexDef) {
const ddoc = indexDef.ddoc;
const type = indexDef.type || 'json';
const name = indexDef.name;
if (!ddoc) {
throw new Error('you must provide an index\'s ddoc');
}
if (!name) {
throw new Error('you must provide an index\'s name');
}
const url = '_index/' + [ddoc, type, name].map(encodeURIComponent).join('/');
return await dbFetch(db, url, { method: 'DELETE' });
}
//
// One thing about these mappers:
//
// Per the advice of John-David Dalton (http://youtu.be/NthmeLEhDDM),
// what you want to do in this case is optimize for the smallest possible
// function, since that's the thing that gets run over and over again.
//
// This code would be a lot simpler if all the if/elses were inside
// the function, but it would also be a lot less performant.
//
function getDeepValue(value, path) {
for (const key of path) {
value = value[key];
if (value === undefined) {
return undefined;
}
}
return value;
}
function createDeepMultiMapper(fields, emit, selector) {
return function (doc) {
if (selector && !pouchdbSelectorCore.matchesSelector(doc, selector)) { return; }
const toEmit = [];
for (const field of fields) {
const value = getDeepValue(doc, pouchdbSelectorCore.parseField(field));
if (value === undefined) {
return;
}
toEmit.push(value);
}
emit(toEmit);
};
}
function createDeepSingleMapper(field, emit, selector) {
const parsedField = pouchdbSelectorCore.parseField(field);
return function (doc) {
if (selector && !pouchdbSelectorCore.matchesSelector(doc, selector)) { return; }
const value = getDeepValue(doc, parsedField);
if (value !== undefined) {
emit(value);
}
};
}
function createShallowSingleMapper(field, emit, selector) {
return function (doc) {
if (selector && !pouchdbSelectorCore.matchesSelector(doc, selector)) { return; }
emit(doc[field]);
};
}
function createShallowMultiMapper(fields, emit, selector) {
return function (doc) {
if (selector && !pouchdbSelectorCore.matchesSelector(doc, selector)) { return; }
const toEmit = fields.map(field => doc[field]);
emit(toEmit);
};
}
function checkShallow(fields) {
return fields.every((field) => field.indexOf('.') === -1);
}
function createMapper(fields, emit, selector) {
const isShallow = checkShallow(fields);
const isSingle = fields.length === 1;
// notice we try to optimize for the most common case,
// i.e. single shallow indexes
if (isShallow) {
if (isSingle) {
return createShallowSingleMapper(fields[0], emit, selector);
} else { // multi
return createShallowMultiMapper(fields, emit, selector);
}
} else { // deep
if (isSingle) {
return createDeepSingleMapper(fields[0], emit, selector);
} else { // multi
return createDeepMultiMapper(fields, emit, selector);
}
}
}
function mapper(mapFunDef, emit) {
// mapFunDef is a list of fields
const fields = Object.keys(mapFunDef.fields);
const partialSelector = mapFunDef.partial_filter_selector;
return createMapper(fields, emit, partialSelector);
}
/* istanbul ignore next */
function reducer(/*reduceFunDef*/) {
throw new Error('reduce not supported');
}
function ddocValidator(ddoc, viewName) {
const view = ddoc.views[viewName];
// This doesn't actually need to be here apparently, but
// I feel safer keeping it.
/* istanbul ignore if */
if (!view.map || !view.map.fields) {
throw new Error('ddoc ' + ddoc._id + ' with view ' + viewName +
' doesn\'t have map.fields defined. ' +
'maybe it wasn\'t created by this plugin?');
}
}
const abstractMapper = abstractMapReduce(
/* localDocName */ 'indexes',
mapper,
reducer,
ddocValidator
);
function abstractMapper$1 (db) {
if (db._customFindAbstractMapper) {
return {
// Calls the _customFindAbstractMapper, but with a third argument:
// the standard findAbstractMapper query/viewCleanup.
// This allows the indexeddb adapter to support partial_filter_selector.
query: function addQueryFallback(signature, opts) {
const fallback = abstractMapper.query.bind(this);
return db._customFindAbstractMapper.query.call(this, signature, opts, fallback);
},
viewCleanup: function addViewCleanupFallback() {
const fallback = abstractMapper.viewCleanup.bind(this);
return db._customFindAbstractMapper.viewCleanup.call(this, fallback);
}
};
}
return abstractMapper;
}
// normalize the "sort" value
function massageSort(sort) {
if (!Array.isArray(sort)) {
throw new Error('invalid sort json - should be an array');
}
return sort.map(function (sorting) {
if (typeof sorting === 'string') {
const obj = {};
obj[sorting] = 'asc';
return obj;
} else {
return sorting;
}
});
}
const ddocIdPrefix = /^_design\//;
function massageUseIndex(useIndex) {
let cleanedUseIndex = [];
if (typeof useIndex === 'string') {
cleanedUseIndex.push(useIndex);
} else {
cleanedUseIndex = useIndex;
}
return cleanedUseIndex.map(function (name) {
return name.replace(ddocIdPrefix, '');
});
}
function massageIndexDef(indexDef) {
indexDef.fields = indexDef.fields.map(function (field) {
if (typeof field === 'string') {
const obj = {};
obj[field] = 'asc';
return obj;
}
return field;
});
if (indexDef.partial_filter_selector) {
indexDef.partial_filter_selector = pouchdbSelectorCore.massageSelector(
indexDef.partial_filter_selector
);
}
return indexDef;
}
function getKeyFromDoc(doc, index) {
return index.def.fields.map((obj) => {
const field = pouchdbSelectorCore.getKey(obj);
return pouchdbSelectorCore.getFieldFromDoc(doc, pouchdbSelectorCore.parseField(field));
});
}
// have to do this manually because REASONS. I don't know why
// CouchDB didn't implement inclusive_start
function filterInclusiveStart(rows, targetValue, index) {
const indexFields = index.def.fields;
let startAt = 0;
for (const row of rows) {
// shave off any docs at the beginning that are <= the
// target value
let docKey = getKeyFromDoc(row.doc, index);
if (indexFields.length === 1) {
docKey = docKey[0]; // only one field, not multi-field
} else { // more than one field in index
// in the case where e.g. the user is searching {$gt: {a: 1}}
// but the index is [a, b], then we need to shorten the doc key
while (docKey.length > targetValue.length) {
docKey.pop();
}
}
//ABS as we just looking for values that don't match
if (Math.abs(pouchdbCollate.collate(docKey, targetValue)) > 0) {
// no need to filter any further; we're past the key
break;
}
++startAt;
}
return startAt > 0 ? rows.slice(startAt) : rows;
}
function reverseOptions(opts) {
const newOpts = pouchdbUtils.clone(opts);
delete newOpts.startkey;
delete newOpts.endkey;
delete newOpts.inclusive_start;
delete newOpts.inclusive_end;
if ('endkey' in opts) {
newOpts.startkey = opts.endkey;
}
if ('startkey' in opts) {
newOpts.endkey = opts.startkey;
}
if ('inclusive_start' in opts) {
newOpts.inclusive_end = opts.inclusive_start;
}
if ('inclusive_end' in opts) {
newOpts.inclusive_start = opts.inclusive_end;
}
return newOpts;
}
function validateIndex(index) {
const ascFields = index.fields.filter(function (field) {
return pouchdbSelectorCore.getValue(field) === 'asc';
});
if (ascFields.length !== 0 && ascFields.length !== index.fields.length) {
throw new Error('unsupported mixed sorting');
}
}
function validateSort(requestDef, index) {
if (index.defaultUsed && requestDef.sort) {
const noneIdSorts = requestDef.sort.filter(function (sortItem) {
return Object.keys(sortItem)[0] !== '_id';
}).map(function (sortItem) {
return Object.keys(sortItem)[0];
});
if (noneIdSorts.length > 0) {
throw new Error('Cannot sort on field(s) "' + noneIdSorts.join(',') +
'" when using the default index');
}
}
if (index.defaultUsed) {
return;
}
}
function validateFindRequest(requestDef) {
if (typeof requestDef.selector !== 'object') {
throw new Error('you must provide a selector when you find()');
}
/*var selectors = requestDef.selector['$and'] || [requestDef.selector];
for (var i = 0; i < selectors.length; i++) {
var selector = selectors[i];
var keys = Object.keys(selector);
if (keys.length === 0) {
throw new Error('invalid empty selector');
}
//var selection = selector[keys[0]];
/*if (Object.keys(selection).length !== 1) {
throw new Error('invalid selector: ' + JSON.stringify(selection) +
' - it must have exactly one key/value');
}
}*/
}
// determine the maximum number of fields
// we're going to need to query, e.g. if the user
// has selection ['a'] and sorting ['a', 'b'], then we
// need to use the longer of the two: ['a', 'b']
function getUserFields(selector, sort) {
const selectorFields = Object.keys(selector);
const sortFields = sort ? sort.map(pouchdbSelectorCore.getKey) : [];
let userFields;
if (selectorFields.length >= sortFields.length) {
userFields = selectorFields;
} else {
userFields = sortFields;
}
if (sortFields.length === 0) {
return {
fields: userFields
};
}
// sort according to the user's preferred sorting
userFields = userFields.sort(function (left, right) {
let leftIdx = sortFields.indexOf(left);
if (leftIdx === -1) {
leftIdx = Number.MAX_VALUE;
}
let rightIdx = sortFields.indexOf(right);
if (rightIdx === -1) {
rightIdx = Number.MAX_VALUE;
}
return leftIdx < rightIdx ? -1 : leftIdx > rightIdx ? 1 : 0;
});
return {
fields: userFields,
sortOrder: sort.map(pouchdbSelectorCore.getKey)
};
}
async function createIndex$1(db, requestDef) {
requestDef = massageCreateIndexRequest(requestDef);
const originalIndexDef = pouchdbUtils.clone(requestDef.index);
requestDef.index = massageIndexDef(requestDef.index);
validateIndex(requestDef.index);
// calculating md5 is expensive - memoize and only
// run if required
let md5;
function getMd5() {
return md5 || (md5 = pouchdbMd5.stringMd5(JSON.stringify(requestDef)));
}
const viewName = requestDef.name || ('idx-' + getMd5());
const ddocName = requestDef.ddoc || ('idx-' + getMd5());
const ddocId = '_design/' + ddocName;
let hasInvalidLanguage = false;
let viewExists = false;
function updateDdoc(doc) {
if (doc._rev && doc.language !== 'query') {
hasInvalidLanguage = true;
}
doc.language = 'query';
doc.views = doc.views || {};
viewExists = !!doc.views[viewName];
if (viewExists) {
return false;
}
doc.views[viewName] = {
map: {
fields: mergeObjects(requestDef.index.fields),
partial_filter_selector: requestDef.index.partial_filter_selector
},
reduce: '_count',
options: {
def: originalIndexDef
}
};
return doc;
}
db.constructor.emit('debug', ['find', 'creating index', ddocId]);
await pouchdbUtils.upsert(db, ddocId, updateDdoc);
if (hasInvalidLanguage) {
throw new Error('invalid language for ddoc with id "' +
ddocId +
'" (should be "query")');
}
// kick off a build
// TODO: abstract-pouchdb-mapreduce should support auto-updating
// TODO: should also use update_after, but pouchdb/pouchdb#3415 blocks me
const signature = ddocName + '/' + viewName;
await abstractMapper$1(db).query.call(db, signature, {
limit: 0,
reduce: false
});
return {
id: ddocId,
name: viewName,
result: viewExists ? 'exists' : 'created'
};
}
async function getIndexes$1(db) {
// just search through all the design docs and filter in-memory.
// hopefully there aren't that many ddocs.
const allDocsRes = await db.allDocs({
startkey: '_design/',
endkey: '_design/\uffff',
include_docs: true
});
const res = {
indexes: [{
ddoc: null,
name: '_all_docs',
type: 'special',
def: {
fields: [{ _id: 'asc' }]
}
}]
};
res.indexes = flatten(res.indexes, allDocsRes.rows.filter(function (row) {
return row.doc.language === 'query';
}).map(function (row) {
const viewNames = row.doc.views !== undefined ? Object.keys(row.doc.views) : [];
return viewNames.map(function (viewName) {
const view = row.doc.views[viewName];
return {
ddoc: row.id,
name: viewName,
type: 'json',
def: massageIndexDef(view.options.def)
};
});
}));
// these are sorted by view name for some reason
res.indexes.sort(function (left, right) {
return pouchdbSelectorCore.compare(left.name, right.name);
});
res.total_rows = res.indexes.length;
return res;
}
// couchdb lowest collation value
const COLLATE_LO = null;
// couchdb highest collation value (TODO: well not really, but close enough amirite)
const COLLATE_HI = { "\uffff": {} };
const SHORT_CIRCUIT_QUERY = {
queryOpts: { limit: 0, startkey: COLLATE_HI, endkey: COLLATE_LO },
inMemoryFields: [],
};
// couchdb second-lowest collation value
function checkFieldInIndex(index, field) {
return index.def.fields
.some((key) => pouchdbSelectorCore.getKey(key) === field);
}
// so when you do e.g. $eq/$eq, we can do it entirely in the database.
// but when you do e.g. $gt/$eq, the first part can be done
// in the database, but the second part has to be done in-memory,
// because $gt has forced us to lose precision.
// so that's what this determines
function userOperatorLosesPrecision(selector, field) {
const matcher = selector[field];
const userOperator = pouchdbSelectorCore.getKey(matcher);
return userOperator !== '$eq';
}
// sort the user fields by their position in the index,
// if they're in the index
function sortFieldsByIndex(userFields, index) {
const indexFields = index.def.fields.map(pouchdbSelectorCore.getKey);
return userFields.slice().sort(function (a, b) {
let aIdx = indexFields.indexOf(a);
let bIdx = indexFields.indexOf(b);
if (aIdx === -1) {
aIdx = Number.MAX_VALUE;
}
if (bIdx === -1) {
bIdx = Number.MAX_VALUE;
}
return pouchdbSelectorCore.compare(aIdx, bIdx);
});
}
// first pass to try to find fields that will need to be sorted in-memory
function getBasicInMemoryFields(index, selector, userFields) {
userFields = sortFieldsByIndex(userFields, index);
// check if any of the user selectors lose precision
let needToFilterInMemory = false;
for (let i = 0, len = userFields.length; i < len; i++) {
const field = userFields[i];
if (needToFilterInMemory || !checkFieldInIndex(index, field)) {
return userFields.slice(i);
}
if (i < len - 1 && userOperatorLosesPrecision(selector, field)) {
needToFilterInMemory = true;
}
}
return [];
}
function getInMemoryFieldsFromNe(selector) {
const fields = [];
for (const [field, matcher] of Object.entries(selector)) {
for (const operator of Object.keys(matcher)) {
if (operator === '$ne') {
fields.push(field);
}
}
}
return fields;
}
function getInMemoryFields(coreInMemoryFields, index, selector, userFields) {
const result = flatten(
// in-memory fields reported as necessary by the query planner
coreInMemoryFields,
// combine with another pass that checks for any we may have missed
getBasicInMemoryFields(index, selector, userFields),
// combine with another pass that checks for $ne's
getInMemoryFieldsFromNe(selector)
);
return sortFieldsByIndex(uniq(result), index);
}
// check that at least one field in the user's query is represented
// in the index. order matters in the case of sorts
function checkIndexFieldsMatch(indexFields, sortOrder, fields) {
if (sortOrder) {
// array has to be a strict subarray of index array. furthermore,
// the sortOrder fields need to all be represented in the index
const sortMatches = oneArrayIsStrictSubArrayOfOther(sortOrder, indexFields);
const selectorMatches = oneArrayIsSubArrayOfOther(fields, indexFields);
return sortMatches && selectorMatches;
}
// all of the user's specified fields still need to be
// on the left side of the index array, although the order
// doesn't matter
return oneSetIsSubArrayOfOther(fields, indexFields);
}
const logicalMatchers = ['$eq', '$gt', '$gte', '$lt', '$lte'];
function isNonLogicalMatcher(matcher) {
return logicalMatchers.indexOf(matcher) === -1;
}
// check all the index fields for usages of '$ne'
// e.g. if the user queries {foo: {$ne: 'foo'}, bar: {$eq: 'bar'}},
// then we can neither use an index on ['foo'] nor an index on
// ['foo', 'bar'], but we can use an index on ['bar'] or ['bar', 'foo']
function checkFieldsLogicallySound(indexFields, selector) {
const firstField = indexFields[0];
const matcher = selector[firstField];
if (typeof matcher === 'undefined') {
/* istanbul ignore next */
return true;
}
const isInvalidNe = Object.keys(matcher).length === 1 &&
pouchdbSelectorCore.getKey(matcher) === '$ne';
return !isInvalidNe;
}
function checkIndexMatches(index, sortOrder, fields, selector) {
const indexFields = index.def.fields.map(pouchdbSelectorCore.getKey);
const fieldsMatch = checkIndexFieldsMatch(indexFields, sortOrder, fields);
if (!fieldsMatch) {
return false;
}
return checkFieldsLogicallySound(indexFields, selector);
}
//
// the algorithm is very simple:
// take all the fields the user supplies, and if those fields
// are a strict subset of the fields in some index,
// then use that index
//
function findMatchingIndexes(selector, userFields, sortOrder, indexes) {
return indexes.filter(function (index) {
return checkIndexMatches(index, sortOrder, userFields, selector);
});
}
// find the best index, i.e. the one that matches the most fields
// in the user's query
function findBestMatchingIndex(selector, userFields, sortOrder, indexes, useIndex) {
const matchingIndexes = findMatchingIndexes(selector, userFields, sortOrder, indexes);
if (matchingIndexes.length === 0) {
if (useIndex) {
throw {
error: "no_usable_index",
message: "There is no index available for this selector."
};
}
// return `all_docs` as a default index;
// I'm assuming that _all_docs is always first
const defaultIndex = indexes[0];
defaultIndex.defaultUsed = true;
return defaultIndex;
}
if (matchingIndexes.length === 1 && !useIndex) {
return matchingIndexes[0];
}
const userFieldsMap = arrayToObject(userFields);
function scoreIndex(index) {
const indexFields = index.def.fields.map(pouchdbSelectorCore.getKey);
let score = 0;
for (const indexField of indexFields) {
if (userFieldsMap[indexField]) {
score++;
}
}
return score;
}
if (useIndex) {
const useIndexDdoc = '_design/' + useIndex[0];
const useIndexName = useIndex.length === 2 ? useIndex[1] : false;
const index = matchingIndexes.find(function (index) {
if (useIndexName && index.ddoc === useIndexDdoc && useIndexName === index.name) {
return true;
}
if (index.ddoc === useIndexDdoc) {
/* istanbul ignore next */
return true;
}
return false;
});
if (!index) {
throw {
error: "unknown_error",
message: "Could not find that index or could not use that index for the query"
};
}
return index;
}
return max(matchingIndexes, scoreIndex);
}
function getSingleFieldQueryOptsFor(userOperator, userValue) {
switch (userOperator) {
case '$eq':
return { key: userValue };
case '$lte':
return { endkey: userValue };
case '$gte':
return { startkey: userValue };
case '$lt':
return {
endkey: userValue,
inclusive_end: false
};
case '$gt':
return {
startkey: userValue,
inclusive_start: false
};
}
return {
startkey: COLLATE_LO
};
}
function getSingleFieldCoreQueryPlan(selector, index) {
const field = pouchdbSelectorCore.getKey(index.def.fields[0]);
//ignoring this because the test to exercise the branch is skipped at the moment
/* istanbul ignore next */
const matcher = selector[field] || {};
const inMemoryFields = [];
const userOperators = Object.keys(matcher);
let combinedOpts;
for (const userOperator of userOperators) {
if (isNonLogicalMatcher(userOperator)) {
inMemoryFields.push(field);
}
const userValue = matcher[userOperator];
const newQueryOpts = getSingleFieldQueryOptsFor(userOperator, userValue);
if (combinedOpts) {
combinedOpts = mergeObjects([combinedOpts, newQueryOpts]);
} else {
combinedOpts = newQueryOpts;
}
}
return {
queryOpts: combinedOpts,
inMemoryFields
};
}
function getMultiFieldCoreQueryPlan(userOperator, userValue) {
switch (userOperator) {
case '$eq':
return {
startkey: userValue,
endkey: userValue
};
case '$lte':
return {
endkey: userValue
};
case '$gte':
return {
startkey: userValue
};
case '$lt':
return {
endkey: userValue,
inclusive_end: false
};
case '$gt':
return {
startkey: userValue,
inclusive_start: false
};
}
}
function getMultiFieldQueryOpts(selector, index) {
const indexFields = index.def.fields.map(pouchdbSelectorCore.getKey);
let inMemoryFields = [];
const startkey = [];
const endkey = [];
let inclusiveStart;
let inclusiveEnd;
function finish(i) {
if (inclusiveStart !== false) {
startkey.push(COLLATE_LO);
}
if (inclusiveEnd !== false) {
endkey.push(COLLATE_HI);
}
// keep track of the fields where we lost specificity,
// and therefore need to filter in-memory
inMemoryFields = indexFields.slice(i);
}
for (let i = 0, len = indexFields.length; i < len; i++) {
const indexField = indexFields[i];
const matcher = selector[indexField];
if (!matcher || !Object.keys(matcher).length) { // fewer fields in user query than in index
finish(i);
break;
} else if (Object.keys(matcher).some(isNonLogicalMatcher)) { // non-logical are ignored
finish(i);
break;
} else if (i > 0) {
const usingGtlt = (
'$gt' in matcher || '$gte' in matcher ||
'$lt' in matcher || '$lte' in matcher);
const previousKeys = Object.keys(selector[indexFields[i - 1]]);
const previousWasEq = arrayEquals(previousKeys, ['$eq']);
const previousWasSame = arrayEquals(previousKeys, Object.keys(matcher));
const gtltLostSpecificity = usingGtlt && !previousWasEq && !previousWasSame;
if (gtltLostSpecificity) {
finish(i);
break;
}
}
const userOperators = Object.keys(matcher);
let combinedOpts = null;
for (const userOperator of userOperators) {
const userValue = matcher[userOperator];
const newOpts = getMultiFieldCoreQueryPlan(userOperator, userValue);
if (combinedOpts) {
combinedOpts = mergeObjects([combinedOpts, newOpts]);
} else {
combinedOpts = newOpts;
}
}
startkey.push('startkey' in combinedOpts ? combinedOpts.startkey : COLLATE_LO);
endkey.push('endkey' in combinedOpts ? combinedOpts.endkey : COLLATE_HI);
if ('inclusive_start' in combinedOpts) {
inclusiveStart = combinedOpts.inclusive_start;
}
if ('inclusive_end' in combinedOpts) {
inclusiveEnd = combinedOpts.inclusive_end;
}
}
const res = {
startkey,
endkey
};
if (typeof inclusiveStart !== 'undefined') {
res.inclusive_start = inclusiveStart;
}
if (typeof inclusiveEnd !== 'undefined') {
res.inclusive_end = inclusiveEnd;
}
return {
queryOpts: res,
inMemoryFields
};
}
function shouldShortCircuit(selector) {
// We have a field to select from, but not a valid value
// this should result in a short circuited query
// just like the http adapter (couchdb) and mongodb
// see tests for issue #7810
// @todo Use 'Object.values' when Node.js v6 support is dropped.
const values = Object.keys(selector).map(function (key) {
return selector[key];
});
return values.some(function (val) {
return typeof val === 'object' && Object.keys(val).length === 0;
});
}
function getDefaultQueryPlan(selector) {
//using default index, so all fields need to be done in memory
return {
queryOpts: { startkey: null },
inMemoryFields: [Object.keys(selector)]
};
}
function getCoreQueryPlan(selector, index) {
if (index.defaultUsed) {
return getDefaultQueryPlan(selector, index);
}
if (index.def.fields.length === 1) {
// one field in index, so the value was indexed as a singleton
return getSingleFieldCoreQueryPlan(selector, index);
}
// else index has multiple fields, so the value was indexed as an array
return getMultiFieldQueryOpts(selector, index);
}
function planQuery(request, indexes) {
const selector = request.selector;
const sort = request.sort;
if (shouldShortCircuit(selector)) {
return Object.assign({}, SHORT_CIRCUIT_QUERY, { index: indexes[0] });
}
const userFieldsRes = getUserFields(selector, sort);
const userFields = userFieldsRes.fields;
const sortOrder = userFieldsRes.sortOrder;
const index = findBestMatchingIndex(selector, userFields, sortOrder, indexes, request.use_index);
const coreQueryPlan = getCoreQueryPlan(selector, index);
const queryOpts = coreQueryPlan.queryOpts;
const coreInMemoryFields = coreQueryPlan.inMemoryFields;
const inMemoryFields = getInMemoryFields(coreInMemoryFields, index, selector, userFields);
return {
queryOpts,
index,
inMemoryFields
};
}
function indexToSignature(index) {
// remove '_design/'
return index.ddoc.substring(8) + '/' + index.name;
}
async function doAllDocs(db, originalOpts) {
const opts = pouchdbUtils.clone(originalOpts);
// CouchDB responds in weird ways when you provide a non-string to _id;
// we mimic the behavior for consistency. See issue66 tests for details.
if (opts.descending) {
if ('endkey' in opts && typeof opts.endkey !== 'string') {
opts.endkey = '';
}
if ('startkey' in opts && typeof opts.startkey !== 'string') {
opts.limit = 0;
}
} else {
if ('startkey' in opts && typeof opts.startkey !== 'string') {
opts.startkey = '';
}
if ('endkey' in opts && typeof opts.endkey !== 'string') {
opts.limit = 0;
}
}
if ('key' in opts && typeof opts.key !== 'string') {
opts.limit = 0;
}
if (opts.limit > 0 && opts.indexes_count) {
// brute force and quite naive impl.
// amp up the limit with the amount of (indexes) design docs
// or is this too naive? How about skip?
opts.original_limit = opts.limit;
opts.limit += opts.indexes_count;
}
const res = await db.allDocs(opts);
// filter out any design docs that _all_docs might return
res.rows = res.rows.filter(function (row) {
return !/^_design\//.test(row.id);
});
// put back original limit
if (opts.original_limit) {
opts.limit = opts.original_limit;
}
// enforce the rows to respect the given limit
res.rows = res.rows.slice(0, opts.limit);
return res;
}
async function queryAllOrIndex(db, opts, indexToUse) {
if (indexToUse.name === '_all_docs') {
return doAllDocs(db, opts);
}
return abstractMapper$1(db).query.call(db, indexToSignature(indexToUse), opts);
}
async function find$1(db, requestDef, explain) {
if (requestDef.selector) {
// must be validated before massaging
validateSelector(requestDef.selector, false);
requestDef.selector = pouchdbSelectorCore.massageSelector(requestDef.selector);
}
if (requestDef.sort) {
requestDef.sort = massageSort(requestDef.sort);
}
if (requestDef.use_index) {
requestDef.use_index = massageUseIndex(requestDef.use_index);
}
if (!('limit' in requestDef)) {
// Match the default limit of CouchDB
requestDef.limit = 25;
}
validateFindRequest(requestDef);
const getIndexesRes = await getIndexes$1(db);
db.constructor.emit('debug', ['find', 'planning query', requestDef]);
const queryPlan = planQuery(requestDef, getIndexesRes.indexes);
db.constructor.emit('debug', ['find', 'query plan', queryPlan]);
const indexToUse = queryPlan.index;
validateSort(requestDef, indexToUse);
let opts = Object.assign({
include_docs: true,
reduce: false,
// Add amount of index for doAllDocs to use (related to issue #7810)
indexes_count: getIndexesRes.total_rows,
}, queryPlan.queryOpts);
if ('startkey' in opts && 'endkey' in opts &&
pouchdbCollate.collate(opts.startkey, opts.endkey) > 0) {
// can't possibly return any results, startkey > endkey
/* istanbul ignore next */
return { docs: [] };
}
const isDescending = requestDef.sort &&
typeof requestDef.sort[0] !== 'string' &&
pouchdbSelectorCore.getValue(requestDef.sort[0]) === 'desc';
if (isDescending) {
// either all descending or all ascending
opts.descending = true;
opts = reverseOptions(opts);
}
if (!queryPlan.inMemoryFields.length) {
// no in-memory filtering necessary, so we can let the
// database do the limit/skip for us
opts.limit = requestDef.limit;
if ('skip' in requestDef) {
opts.skip = requestDef.skip;
}
}
if (explain) {
return Promise.resolve(queryPlan, opts);
}
const res = await queryAllOrIndex(db, opts, indexToUse);
if (opts.inclusive_start === false) {
// may have to manually filter the first one,
// since couchdb has no true inclusive_start option
res.rows = filterInclusiveStart(res.rows, opts.startkey, indexToUse);
}
if (queryPlan.inMemoryFields.length) {
// need to filter some stuff in-memory
res.rows = pouchdbSelectorCore.filterInMemoryFields(res.rows, requestDef, queryPlan.inMemoryFields);
}
const resp = {
docs: res.rows.map(function (row) {
const doc = row.doc;
if (requestDef.fields) {
return pick(doc, requestDef.fields);
}
return doc;
})
};
if (indexToUse.defaultUsed) {
resp.warning = 'No matching index found, create an index to optimize query time.';
}
return resp;
}
async function explain$1(db, requestDef) {
const queryPlan = await find$1(db, requestDef, true);
return {
dbname: db.name,
index: queryPlan.index,
selector: requestDef.selector,
range: {
start_key: queryPlan.queryOpts.startkey,
end_key: queryPlan.queryOpts.endkey,
},
opts: {
use_index: requestDef.use_index || [],
bookmark: "nil", //hardcoded to match CouchDB since its not supported,
limit: requestDef.limit,
skip: requestDef.skip,
sort: requestDef.sort || {},
fields: requestDef.fields,
conflicts: false, //hardcoded to match CouchDB since its not supported,
r: [49], // hardcoded to match CouchDB since its not support
},
limit: requestDef.limit,
skip: requestDef.skip || 0,
fields: requestDef.fields,
};
}
async function deleteIndex$1(db, index) {
if (!index.ddoc) {
throw new Error('you must supply an index.ddoc when deleting');
}
if (!index.name) {
throw new Error('you must supply an index.name when deleting');
}
const docId = index.ddoc;
const viewName = index.name;
function deltaFun(doc) {
if (Object.keys(doc.views).length === 1 && doc.views[viewName]) {
// only one view in this ddoc, delete the whole ddoc
return {_id: docId, _deleted: true};
}
// more than one view here, just remove the view
delete doc.views[viewName];
return doc;
}
await pouchdbUtils.upsert(db, docId, deltaFun);
await abstractMapper$1(db).viewCleanup.apply(db);
return { ok: true };
}
const plugin = {};
plugin.createIndex = resolveToCallback(async function (requestDef) {
if (typeof requestDef !== 'object') {
throw new Error('you must provide an index to create');
}
const createIndex$$1 = pouchdbUtils.isRemote(this) ?
createIndex : createIndex$1;
return createIndex$$1(this, requestDef);
});
plugin.find = resolveToCallback(async function (requestDef) {
if (typeof requestDef !== 'object') {
throw new Error('you must provide search parameters to find()');
}
const find$$1 = pouchdbUtils.isRemote(this) ? find : find$1;
return find$$1(this, requestDef);
});
plugin.explain = resolveToCallback(async function (requestDef) {
if (typeof requestDef !== 'object') {
throw new Error('you must provide search parameters to explain()');
}
const find$$1 = pouchdbUtils.isRemote(this) ? explain : explain$1;
return find$$1(this, requestDef);
});
plugin.getIndexes = resolveToCallback(async function () {
const getIndexes$$1 = pouchdbUtils.isRemote(this) ? getIndexes : getIndexes$1;
return getIndexes$$1(this);
});
plugin.deleteIndex = resolveToCallback(async function (indexDef) {
if (typeof indexDef !== 'object') {
throw new Error('you must provide an index to delete');
}
const deleteIndex$$1 = pouchdbUtils.isRemote(this) ?
deleteIndex : deleteIndex$1;
return deleteIndex$$1(this, indexDef);
});
module.exports = plugin;