UNPKG

pouchdb-find

Version:
697 lines (627 loc) 21.9 kB
'use strict'; var pouchCollate = require('pouchdb-collate'); var TaskQueue = require('./taskqueue'); var collate = pouchCollate.collate; var toIndexableString = pouchCollate.toIndexableString; var normalizeKey = pouchCollate.normalizeKey; var createView = require('./create-view'); var log; /* istanbul ignore else */ if ((typeof console !== 'undefined') && (typeof console.log === 'function')) { log = Function.prototype.bind.call(console.log, console); } else { log = function () {}; } var utils = require('./utils'); var Promise = utils.Promise; var persistentQueues = {}; var tempViewQueue = new TaskQueue(); var CHANGES_BATCH_SIZE = 50; function QueryParseError(message) { this.status = 400; this.name = 'query_parse_error'; this.message = message; this.error = true; try { Error.captureStackTrace(this, QueryParseError); } catch (e) {} } utils.inherits(QueryParseError, Error); function NotFoundError(message) { this.status = 404; this.name = 'not_found'; this.message = message; this.error = true; try { Error.captureStackTrace(this, NotFoundError); } catch (e) {} } utils.inherits(NotFoundError, Error); function parseViewName(name) { // can be either 'ddocname/viewname' or just 'viewname' // (where the ddoc name is the same) return name.indexOf('/') === -1 ? [name, name] : name.split('/'); } function isGenOne(changes) { // only return true if the current change is 1- // and there are no other leafs return changes.length === 1 && /^1-/.test(changes[0].rev); } function sortByKeyThenValue(x, y) { var keyCompare = collate(x.key, y.key); return keyCompare !== 0 ? keyCompare : collate(x.value, y.value); } function sliceResults(results, limit, skip) { skip = skip || 0; if (typeof limit === 'number') { return results.slice(skip, limit + skip); } else if (skip > 0) { return results.slice(skip); } return results; } function rowToDocId(row) { var val = row.value; // Users can explicitly specify a joined doc _id, or it // defaults to the doc _id that emitted the key/value. var docId = (val && typeof val === 'object' && val._id) || row.id; return docId; } function emitError(db, e) { try { db.emit('error', e); } catch (err) { console.error( 'The user\'s map/reduce function threw an uncaught error.\n' + 'You can debug this error by doing:\n' + 'myDatabase.on(\'error\', function (err) { debugger; });\n' + 'Please double-check your map/reduce function.'); console.error(e); } } function tryCode(db, fun, args) { // emit an event if there was an error thrown by a map/reduce function. // putting try/catches in a single function also avoids deoptimizations. try { return { output : fun.apply(null, args) }; } catch (e) { emitError(db, e); return {error: e}; } } function checkQueryParseError(options, fun) { var startkeyName = options.descending ? 'endkey' : 'startkey'; var endkeyName = options.descending ? 'startkey' : 'endkey'; if (typeof options[startkeyName] !== 'undefined' && typeof options[endkeyName] !== 'undefined' && collate(options[startkeyName], options[endkeyName]) > 0) { throw new QueryParseError('No rows can match your key range, reverse your ' + 'start_key and end_key or set {descending : true}'); } else if (fun.reduce && options.reduce !== false) { if (options.include_docs) { throw new QueryParseError('{include_docs:true} is invalid for reduce'); } else if (options.keys && options.keys.length > 1 && !options.group && !options.group_level) { throw new QueryParseError('Multi-key fetches for reduce views must use {group: true}'); } } if (options.group_level) { if (typeof options.group_level !== 'number') { throw new QueryParseError('Invalid value for integer: "' + options.group_level + '"'); } if (options.group_level < 0) { throw new QueryParseError('Invalid value for positive integer: ' + '"' + options.group_level + '"'); } } } function defaultsTo(value) { return function (reason) { /* istanbul ignore else */ if (reason.status === 404) { return value; } else { throw reason; } }; } function createIndexer(def) { var pluginName = def.name; var mapper = def.mapper; var reducer = def.reducer; var ddocValidator = def.ddocValidator; // returns a promise for a list of docs to update, based on the input docId. // the order doesn't matter, because post-3.2.0, bulkDocs // is an atomic operation in all three adapters. function getDocsToPersist(docId, view, docIdsToChangesAndEmits) { var metaDocId = '_local/doc_' + docId; var defaultMetaDoc = {_id: metaDocId, keys: []}; var docData = docIdsToChangesAndEmits[docId]; var indexableKeysToKeyValues = docData.indexableKeysToKeyValues; var changes = docData.changes; function getMetaDoc() { if (isGenOne(changes)) { // generation 1, so we can safely assume initial state // for performance reasons (avoids unnecessary GETs) return Promise.resolve(defaultMetaDoc); } return view.db.get(metaDocId).catch(defaultsTo(defaultMetaDoc)); } function getKeyValueDocs(metaDoc) { if (!metaDoc.keys.length) { // no keys, no need for a lookup return Promise.resolve({rows: []}); } return view.db.allDocs({ keys: metaDoc.keys, include_docs: true }); } function processKvDocs(metaDoc, kvDocsRes) { var kvDocs = []; var oldKeysMap = {}; for (var i = 0, len = kvDocsRes.rows.length; i < len; i++) { var row = kvDocsRes.rows[i]; var doc = row.doc; if (!doc) { // deleted continue; } kvDocs.push(doc); oldKeysMap[doc._id] = true; doc._deleted = !indexableKeysToKeyValues[doc._id]; if (!doc._deleted) { var keyValue = indexableKeysToKeyValues[doc._id]; if ('value' in keyValue) { doc.value = keyValue.value; } } } var newKeys = Object.keys(indexableKeysToKeyValues); newKeys.forEach(function (key) { if (!oldKeysMap[key]) { // new doc var kvDoc = { _id: key }; var keyValue = indexableKeysToKeyValues[key]; if ('value' in keyValue) { kvDoc.value = keyValue.value; } kvDocs.push(kvDoc); } }); metaDoc.keys = utils.uniq(newKeys.concat(metaDoc.keys)); kvDocs.push(metaDoc); return kvDocs; } return getMetaDoc().then(function (metaDoc) { return getKeyValueDocs(metaDoc).then(function (kvDocsRes) { return processKvDocs(metaDoc, kvDocsRes); }); }); } // updates all emitted key/value docs and metaDocs in the mrview database // for the given batch of documents from the source database function saveKeyValues(view, docIdsToChangesAndEmits, seq) { var seqDocId = '_local/lastSeq'; return view.db.get(seqDocId) .catch(defaultsTo({_id: seqDocId, seq: 0})) .then(function (lastSeqDoc) { var docIds = Object.keys(docIdsToChangesAndEmits); return Promise.all(docIds.map(function (docId) { return getDocsToPersist(docId, view, docIdsToChangesAndEmits); })).then(function (listOfDocsToPersist) { var docsToPersist = utils.flatten(listOfDocsToPersist); lastSeqDoc.seq = seq; docsToPersist.push(lastSeqDoc); // write all docs in a single operation, update the seq once return view.db.bulkDocs({docs : docsToPersist}); }); }); } function getQueue(view) { var viewName = typeof view === 'string' ? view : view.name; var queue = persistentQueues[viewName]; if (!queue) { queue = persistentQueues[viewName] = new TaskQueue(); } return queue; } function updateView(view) { return utils.sequentialize(getQueue(view), function () { return updateViewInQueue(view); })(); } function updateViewInQueue(view) { // bind the emit function once var mapResults; var doc; function emit(key, value) { var output = {id: doc._id, key: normalizeKey(key)}; // Don't explicitly store the value unless it's defined and non-null. // This saves on storage space, because often people don't use it. if (typeof value !== 'undefined' && value !== null) { output.value = normalizeKey(value); } mapResults.push(output); } var mapFun = mapper(view.mapFun, emit); var currentSeq = view.seq || 0; function processChange(docIdsToChangesAndEmits, seq) { return function () { return saveKeyValues(view, docIdsToChangesAndEmits, seq); }; } var queue = new TaskQueue(); return new Promise(function (resolve, reject) { function complete() { queue.finish().then(function () { view.seq = currentSeq; resolve(); }); } function processNextBatch() { view.sourceDB.changes({ conflicts: true, include_docs: true, style: 'all_docs', since: currentSeq, limit: CHANGES_BATCH_SIZE }).on('complete', function (response) { var results = response.results; if (!results.length) { return complete(); } var docIdsToChangesAndEmits = {}; for (var i = 0, l = results.length; i < l; i++) { var change = results[i]; if (change.doc._id[0] !== '_') { mapResults = []; doc = change.doc; if (!doc._deleted) { tryCode(view.sourceDB, mapFun, [doc]); } mapResults.sort(sortByKeyThenValue); var indexableKeysToKeyValues = {}; var lastKey; for (var j = 0, jl = mapResults.length; j < jl; j++) { var obj = mapResults[j]; var complexKey = [obj.key, obj.id]; if (collate(obj.key, lastKey) === 0) { complexKey.push(j); // dup key+id, so make it unique } var indexableKey = toIndexableString(complexKey); indexableKeysToKeyValues[indexableKey] = obj; lastKey = obj.key; } docIdsToChangesAndEmits[change.doc._id] = { indexableKeysToKeyValues: indexableKeysToKeyValues, changes: change.changes }; } currentSeq = change.seq; } queue.add(processChange(docIdsToChangesAndEmits, currentSeq)); if (results.length < CHANGES_BATCH_SIZE) { return complete(); } return processNextBatch(); }).on('error', onError); /* istanbul ignore next */ function onError(err) { reject(err); } } processNextBatch(); }); } function reduceView(view, results, options) { if (options.group_level === 0) { delete options.group_level; } var shouldGroup = options.group || options.group_level; var reduceFun = reducer(view.reduceFun); var groups = []; var lvl = options.group_level; results.forEach(function (e) { var last = groups[groups.length - 1]; var key = shouldGroup ? e.key : null; // only set group_level for array keys if (shouldGroup && Array.isArray(key) && typeof lvl === 'number') { key = key.length > lvl ? key.slice(0, lvl) : key; } if (last && collate(last.key[0][0], key) === 0) { last.key.push([key, e.id]); last.value.push(e.value); return; } groups.push({key: [ [key, e.id] ], value: [e.value]}); }); for (var i = 0, len = groups.length; i < len; i++) { var e = groups[i]; var reduceTry = tryCode(view.sourceDB, reduceFun, [e.key, e.value, false]); // TODO: can't do instanceof BuiltInError because this class is buried // in mapreduce.js if (reduceTry.error && /BuiltInError/.test(reduceTry.error.constructor)) { // CouchDB returns an error if a built-in errors out throw reduceTry.error; } // CouchDB just sets the value to null if a non-built-in errors out e.value = reduceTry.error ? null : reduceTry.output; e.key = e.key[0][0]; } // no total_rows/offset when reducing return {rows: sliceResults(groups, options.limit, options.skip)}; } function queryView(view, opts) { return utils.sequentialize(getQueue(view), function () { return queryViewInQueue(view, opts); })(); } function queryViewInQueue(view, opts) { var totalRows; var shouldReduce = view.reduceFun && opts.reduce !== false; var skip = opts.skip || 0; if (typeof opts.keys !== 'undefined' && !opts.keys.length) { // equivalent query opts.limit = 0; delete opts.keys; } function fetchFromView(viewOpts) { viewOpts.include_docs = true; return view.db.allDocs(viewOpts).then(function (res) { totalRows = res.total_rows; return res.rows.map(function (result) { // implicit migration - in older versions of PouchDB, // we explicitly stored the doc as {id: ..., key: ..., value: ...} // this is tested in a migration test /* istanbul ignore next */ if ('value' in result.doc && typeof result.doc.value === 'object' && result.doc.value !== null) { var keys = Object.keys(result.doc.value).sort(); // this detection method is not perfect, but it's unlikely the user // emitted a value which was an object with these 3 exact keys var expectedKeys = ['id', 'key', 'value']; if (!(keys < expectedKeys || keys > expectedKeys)) { return result.doc.value; } } var parsedKeyAndDocId = pouchCollate.parseIndexableString(result.doc._id); return { key: parsedKeyAndDocId[0], id: parsedKeyAndDocId[1], value: ('value' in result.doc ? result.doc.value : null) }; }); }); } function onMapResultsReady(rows) { var finalResults; if (shouldReduce) { finalResults = reduceView(view, rows, opts); } else { finalResults = { total_rows: totalRows, offset: skip, rows: rows }; } if (opts.include_docs) { var docIds = utils.uniq(rows.map(rowToDocId)); return view.sourceDB.allDocs({ keys: docIds, include_docs: true, conflicts: opts.conflicts, attachments: opts.attachments, binary: opts.binary }).then(function (allDocsRes) { var docIdsToDocs = {}; allDocsRes.rows.forEach(function (row) { if (row.doc) { docIdsToDocs['$' + row.id] = row.doc; } }); rows.forEach(function (row) { var docId = rowToDocId(row); var doc = docIdsToDocs['$' + docId]; if (doc) { row.doc = doc; } }); return finalResults; }); } else { return finalResults; } } var flatten = function (array) { return array.reduce(function (prev, cur) { return prev.concat(cur); }); }; if (typeof opts.keys !== 'undefined') { var keys = opts.keys; var fetchPromises = keys.map(function (key) { var viewOpts = { startkey : toIndexableString([key]), endkey : toIndexableString([key, {}]) }; return fetchFromView(viewOpts); }); return Promise.all(fetchPromises).then(flatten).then(onMapResultsReady); } else { // normal query, no 'keys' var viewOpts = { descending : opts.descending }; if (typeof opts.startkey !== 'undefined') { viewOpts.startkey = opts.descending ? toIndexableString([opts.startkey, {}]) : toIndexableString([opts.startkey]); } if (typeof opts.endkey !== 'undefined') { var inclusiveEnd = opts.inclusive_end !== false; if (opts.descending) { inclusiveEnd = !inclusiveEnd; } viewOpts.endkey = toIndexableString(inclusiveEnd ? [opts.endkey, {}] : [opts.endkey]); } if (typeof opts.key !== 'undefined') { var keyStart = toIndexableString([opts.key]); var keyEnd = toIndexableString([opts.key, {}]); if (viewOpts.descending) { viewOpts.endkey = keyStart; viewOpts.startkey = keyEnd; } else { viewOpts.startkey = keyStart; viewOpts.endkey = keyEnd; } } if (!shouldReduce) { if (typeof opts.limit === 'number') { viewOpts.limit = opts.limit; } viewOpts.skip = skip; } return fetchFromView(viewOpts).then(onMapResultsReady); } } function localViewCleanup(db) { return db.get('_local/' + pluginName).then(function (metaDoc) { var docsToViews = {}; Object.keys(metaDoc.views).forEach(function (fullViewName) { var parts = parseViewName(fullViewName); var designDocName = '_design/' + parts[0]; var viewName = parts[1]; docsToViews[designDocName] = docsToViews[designDocName] || {}; docsToViews[designDocName][viewName] = true; }); var opts = { keys : Object.keys(docsToViews), include_docs : true }; return db.allDocs(opts).then(function (res) { var viewsToStatus = {}; res.rows.forEach(function (row) { var ddocName = row.key.substring(8); Object.keys(docsToViews[row.key]).forEach(function (viewName) { var fullViewName = ddocName + '/' + viewName; /* istanbul ignore if */ if (!metaDoc.views[fullViewName]) { // new format, without slashes, to support PouchDB 2.2.0 // migration test in pouchdb's browser.migration.js verifies this fullViewName = viewName; } var viewDBNames = Object.keys(metaDoc.views[fullViewName]); // design doc deleted, or view function nonexistent var statusIsGood = row.doc && row.doc.views && row.doc.views[viewName]; viewDBNames.forEach(function (viewDBName) { viewsToStatus[viewDBName] = viewsToStatus[viewDBName] || statusIsGood; }); }); }); var dbsToDelete = Object.keys(viewsToStatus).filter(function (viewDBName) { return !viewsToStatus[viewDBName]; }); var destroyPromises = dbsToDelete.map(function (viewDBName) { return utils.sequentialize(getQueue(viewDBName), function () { return new db.constructor(viewDBName, db.__opts).destroy(); })(); }); return Promise.all(destroyPromises).then(function () { return {ok: true}; }); }); }, defaultsTo({ok: true})); } function queryPromised(db, fun, opts) { if (typeof fun !== 'string') { // temp_view checkQueryParseError(opts, fun); var createViewOpts = { db : db, viewName : 'temp_view/temp_view', map : fun.map, reduce : fun.reduce, temporary : true, pluginName: pluginName }; tempViewQueue.add(function () { return createView(createViewOpts).then(function (view) { function cleanup() { return view.db.destroy(); } return utils.fin(updateView(view).then(function () { return queryView(view, opts); }), cleanup); }); }); return tempViewQueue.finish(); } else { // persistent view var fullViewName = fun; var parts = parseViewName(fullViewName); var designDocName = parts[0]; var viewName = parts[1]; return db.get('_design/' + designDocName).then(function (doc) { var fun = doc.views && doc.views[viewName]; if (!fun) { // basic validator; it's assumed that every subclass would want this throw new NotFoundError('ddoc ' + doc._id + ' has no view named ' + viewName); } ddocValidator(doc, viewName); checkQueryParseError(opts, fun); var createViewOpts = { db : db, viewName : fullViewName, map : fun.map, reduce : fun.reduce, pluginName: pluginName }; return createView(createViewOpts).then(function (view) { if (opts.stale === 'ok' || opts.stale === 'update_after') { if (opts.stale === 'update_after') { process.nextTick(function () { updateView(view); }); } return queryView(view, opts); } else { // stale not ok return updateView(view).then(function () { return queryView(view, opts); }); } }); }); } } var query = function (fun, opts, callback) { var db = this; if (typeof opts === 'function') { callback = opts; opts = {}; } opts = utils.extend(true, {}, opts); if (typeof fun === 'function') { fun = {map : fun}; } var promise = Promise.resolve().then(function () { return queryPromised(db, fun, opts); }); utils.promisedCallback(promise, callback); return promise; }; var viewCleanup = utils.callbackify(function () { var db = this; return localViewCleanup(db); }); return { query: query, viewCleanup: viewCleanup }; } module.exports = createIndexer;