UNPKG

pouchdb-find

Version:

Easy-to-use query language for PouchDB

1,533 lines (1,317 loc) 43.4 kB
'use strict'; 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;