UNPKG

rxdb

Version:

A local-first realtime NoSQL Database for JavaScript applications - https://rxdb.info/

746 lines (702 loc) 26.3 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.RxQueryBase = void 0; exports._getDefaultQuery = _getDefaultQuery; exports.createRxQuery = createRxQuery; exports.isFindOneByIdQuery = isFindOneByIdQuery; exports.isRxQuery = isRxQuery; exports.queryCollection = queryCollection; exports.tunnelQueryCache = tunnelQueryCache; var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass")); var _rxjs = require("rxjs"); var _operators = require("rxjs/operators"); var _index = require("./plugins/utils/index.js"); var _rxError = require("./rx-error.js"); var _hooks = require("./hooks.js"); var _eventReduce = require("./event-reduce.js"); var _queryCache = require("./query-cache.js"); var _rxQueryHelper = require("./rx-query-helper.js"); var _rxQuerySingleResult = require("./rx-query-single-result.js"); var _queryCount = 0; var newQueryID = function () { return ++_queryCount; }; /** * Counter for _lastEnsureEqual. * We only need ordering and zero-check for cache replacement, * so a counter is cheaper than Date.now(). */ var _ensureEqualCount = 0; var RxQueryBase = exports.RxQueryBase = /*#__PURE__*/function () { function RxQueryBase(op, mangoQuery, collection, // used by some plugins other = {}) { this.id = newQueryID(); this._execOverDatabaseCount = 0; this._creationTime = Date.now(); this._lastEnsureEqual = 0; this.uncached = false; this._refCount$ = null; this._result = null; this._latestChangeEvent = -1; this._ensureEqualQueue = _index.PROMISE_RESOLVE_FALSE; this.op = op; this.mangoQuery = mangoQuery; this.collection = collection; this.other = other; if (!mangoQuery) { this.mangoQuery = _getDefaultQuery(); } /** * @performance * isFindOneByIdQuery is only used by queryCollection() * which is not called for 'count' queries. * Skip the check for count queries to avoid unnecessary work. */ if (op === 'count') { this.isFindOneByIdQuery = false; } else { this.isFindOneByIdQuery = isFindOneByIdQuery(this.collection.schema.primaryPath, mangoQuery); } } var _proto = RxQueryBase.prototype; /** * Returns an observable that emits the results * This should behave like an rxjs-BehaviorSubject which means: * - Emit the current result-set on subscribe * - Emit the new result-set when an RxChangeEvent comes in * - Do not emit anything before the first result-set was created (no null) */ /** * set the new result-data as result-docs of the query * @param newResultData json-docs that were received from the storage */ _proto._setResultData = function _setResultData(newResultData) { if (typeof newResultData === 'undefined') { throw (0, _rxError.newRxError)('QU18', { database: this.collection.database.name, collection: this.collection.name }); } if (typeof newResultData === 'number') { this._result = new _rxQuerySingleResult.RxQuerySingleResult(this, [], newResultData); return; } else if (newResultData instanceof Map) { newResultData = Array.from(newResultData.values()); } var newQueryResult = new _rxQuerySingleResult.RxQuerySingleResult(this, newResultData, newResultData.length); this._result = newQueryResult; } /** * executes the query on the database * @return results-array with document-data */; _proto._execOverDatabase = async function _execOverDatabase(rerunCount = 0) { this._execOverDatabaseCount = this._execOverDatabaseCount + 1; var result; /** * @performance * Instead of subscribing to eventBulks$ to detect concurrent writes, * we snapshot the change event counter before and after the query. * If the counter changed, a write happened during execution and * we must re-run the query to ensure correct results. * This avoids the overhead of RxJS Subject subscribe/unsubscribe per query. * * @link https://github.com/pubkey/rxdb/issues/7067 */ var counterBefore = this.collection._changeEventBuffer.getCounter(); if (this.op === 'findByIds') { var ids = (0, _index.ensureNotFalsy)(this.mangoQuery.selector)[this.collection.schema.primaryPath].$in; var docsData = []; var mustBeQueried = []; // first try to fill from docCache for (var i = 0; i < ids.length; i++) { var id = ids[i]; var docData = this.collection._docCache.getLatestDocumentDataIfExists(id); if (docData) { if (!docData._deleted) { docsData.push(docData); } } else { mustBeQueried.push(id); } } // everything which was not in docCache must be fetched from the storage if (mustBeQueried.length > 0) { var docs = await this.collection.storageInstance.findDocumentsById(mustBeQueried, false); for (var _i = 0; _i < docs.length; _i++) { docsData.push(docs[_i]); } } result = { result: docsData, counter: this.collection._changeEventBuffer.getCounter() }; } else if (this.op === 'count') { var preparedQuery = this.getPreparedQuery(); var countResult = await this.collection.storageInstance.count(preparedQuery); if (countResult.mode === 'slow' && !this.collection.database.allowSlowCount) { throw (0, _rxError.newRxError)('QU14', { collection: this.collection, queryObj: this.mangoQuery }); } else { result = { result: countResult.count, counter: this.collection._changeEventBuffer.getCounter() }; } } else { var queryResult = await queryCollection(this); result = { result: queryResult.docs, counter: queryResult.counter }; } if (this.collection._changeEventBuffer.getCounter() !== counterBefore) { await (0, _index.promiseWait)(rerunCount * 20); return this._execOverDatabase(rerunCount + 1); } return result; } /** * Execute the query * To have an easier implementations, * just subscribe and use the first result */; _proto.exec = async function exec(throwIfMissing) { if (throwIfMissing && this.op !== 'findOne') { throw (0, _rxError.newRxError)('QU9', { collection: this.collection.name, query: this.mangoQuery, op: this.op }); } /** * run _ensureEqual() here, * this will make sure that errors in the query which throw inside of the RxStorage, * will be thrown at this execution context and not in the background. */ await _ensureEqual(this); var useResult = (0, _index.ensureNotFalsy)(this._result); return useResult.getValue(throwIfMissing); } /** * Returns the normalized query. * Caches the result so that multiple calls to * queryMatcher, toString() and getPreparedQuery() * do not have to run the normalization again. * @overwrites itself with the actual value. */; /** * returns a string that is used for equal-comparisons * @overwrites itself with the actual value */ _proto.toString = function toString() { /** * For findByIds queries, build the cache key directly from the IDs * to avoid the expensive normalizeMangoQuery + sortObject + JSON.stringify. * The selector structure is guaranteed by findByIds() which always creates * { [primaryPath]: { $in: ids } } */ var value; if (this.op === 'findByIds') { var ids = this.mangoQuery.selector[this.collection.schema.primaryPath].$in; // slice() is needed because sort() mutates the array in-place var sortedIds = ids.slice().sort(); value = '|findByIds|' + JSON.stringify(sortedIds); } else { var stringObj = (0, _index.sortObject)({ op: this.op, query: this.normalizedQuery, other: this.other }, true); value = JSON.stringify(stringObj); } this.toString = () => value; return value; } /** * returns the prepared query * which can be sent to the storage instance to query for documents. * @overwrites itself with the actual value. */; _proto.getPreparedQuery = function getPreparedQuery() { var hookInput = { rxQuery: this, // can be mutated by the hooks so we have to deep clone first. mangoQuery: (0, _index.clone)(this.normalizedQuery) }; hookInput.mangoQuery.selector._deleted = { $eq: false }; if (hookInput.mangoQuery.index) { hookInput.mangoQuery.index.unshift('_deleted'); } (0, _hooks.runPluginHooks)('prePrepareQuery', hookInput); var value = (0, _rxQueryHelper.prepareQuery)(this.collection.schema.jsonSchema, hookInput.mangoQuery); this.getPreparedQuery = () => value; return value; } /** * returns true if the document matches the query, * does not use the 'skip' and 'limit' */; _proto.doesDocumentDataMatch = function doesDocumentDataMatch(docData) { // if doc is deleted, it cannot match if (docData._deleted) { return false; } return this.queryMatcher(docData); } /** * deletes all found documents * @return promise with deleted documents */; _proto.remove = async function remove(throwIfMissing) { if (throwIfMissing && this.op !== 'findOne') { throw (0, _rxError.newRxError)('QU9', { collection: this.collection.name, query: this.mangoQuery, op: this.op }); } var docs = await this.exec(); if (Array.isArray(docs)) { var result = await this.collection.bulkRemove(docs); if (result.error.length > 0) { throw (0, _rxError.rxStorageWriteErrorToRxError)(result.error[0]); } else { return result.success; } } else { // findOne() can return null when no document matches if (!docs) { if (throwIfMissing) { throw (0, _rxError.newRxError)('QU10', { collection: this.collection.name, query: this.mangoQuery, op: this.op }); } return null; } return docs.remove(); } }; _proto.incrementalRemove = function incrementalRemove() { return (0, _rxQueryHelper.runQueryUpdateFunction)(this.asRxQuery, doc => doc.incrementalRemove()); } /** * helper function to transform RxQueryBase to RxQuery type */; /** * updates all found documents * @overwritten by plugin (optional) */ _proto.update = function update(_updateObj) { throw (0, _index.pluginMissing)('update'); }; _proto.patch = function patch(_patch) { return (0, _rxQueryHelper.runQueryUpdateFunction)(this.asRxQuery, doc => doc.patch(_patch)); }; _proto.incrementalPatch = function incrementalPatch(patch) { return (0, _rxQueryHelper.runQueryUpdateFunction)(this.asRxQuery, doc => doc.incrementalPatch(patch)); }; _proto.modify = function modify(mutationFunction) { return (0, _rxQueryHelper.runQueryUpdateFunction)(this.asRxQuery, doc => doc.modify(mutationFunction)); }; _proto.incrementalModify = function incrementalModify(mutationFunction) { return (0, _rxQueryHelper.runQueryUpdateFunction)(this.asRxQuery, doc => doc.incrementalModify(mutationFunction)); } // we only set some methods of query-builder here // because the others depend on these ones ; _proto.where = function where(_queryObj) { throw (0, _index.pluginMissing)('query-builder'); }; _proto.sort = function sort(_params) { throw (0, _index.pluginMissing)('query-builder'); }; _proto.skip = function skip(_amount) { throw (0, _index.pluginMissing)('query-builder'); }; _proto.limit = function limit(_amount) { throw (0, _index.pluginMissing)('query-builder'); }; return (0, _createClass2.default)(RxQueryBase, [{ key: "refCount$", get: /** * Some stats then are used for debugging and cache replacement policies */ /** * @performance * Use Date.now() instead of now() for creation time. * The monotonic uniqueness guarantee of now() is not needed here * since _creationTime is only used by the cache replacement policy * for rough lifetime comparisons. */ // used in the query-cache to determine if the RxQuery can be cleaned up. // 0 means never executed. Updated to an incrementing counter on each _ensureEqual call. // used to count the subscribers to the query // Lazy-initialized to avoid BehaviorSubject overhead for .exec()-only queries function () { if (!this._refCount$) { this._refCount$ = new _rxjs.BehaviorSubject(null); } return this._refCount$; } /** * Contains the current result state * or null if query has not run yet. */ }, { key: "$", get: function () { if (!this._$) { var results$ = this.collection.eventBulks$.pipe( /** * Performance shortcut. * Changes to local documents are not relevant for the query. */ (0, _operators.filter)(bulk => !bulk.isLocal), /** * Start once to ensure the querying also starts * when there where no changes. */ (0, _operators.startWith)(null), // ensure query results are up to date. (0, _operators.mergeMap)(() => _ensureEqual(this)), // use the current result set, written by _ensureEqual(). (0, _operators.map)(() => this._result), // do not run stuff above for each new subscriber, only once. (0, _operators.shareReplay)(_index.RXJS_SHARE_REPLAY_DEFAULTS), // do not proceed if result set has not changed. (0, _operators.distinctUntilChanged)((prev, curr) => { if (prev && prev.time === (0, _index.ensureNotFalsy)(curr).time) { return true; } else { return false; } }), (0, _operators.filter)(result => !!result), /** * Map the result set to a single RxDocument or an array, * depending on query type */ (0, _operators.map)(result => { return (0, _index.ensureNotFalsy)(result).getValue(); })); this._$ = (0, _rxjs.merge)(results$, /** * Also add the refCount$ to the query observable * to allow us to count the amount of subscribers. */ this.refCount$.pipe((0, _operators.filter)(() => false))); } return this._$; } }, { key: "$$", get: function () { var reactivity = this.collection.database.getReactivityFactory(); return reactivity.fromObservable(this.$, undefined, this.collection.database); } // stores the changeEvent-number of the last handled change-event /** * ensures that the exec-runs * are not run in parallel */ }, { key: "normalizedQuery", get: function () { return (0, _index.overwriteGetterForCaching)(this, 'normalizedQuery', (0, _rxQueryHelper.normalizeMangoQuery)(this.collection.schema.jsonSchema, this.mangoQuery, this.op === 'count')); } /** * cached call to get the queryMatcher * @overwrites itself with the actual value */ }, { key: "queryMatcher", get: function () { var schema = this.collection.schema.jsonSchema; return (0, _index.overwriteGetterForCaching)(this, 'queryMatcher', (0, _rxQueryHelper.getQueryMatcher)(schema, this.normalizedQuery)); } }, { key: "asRxQuery", get: function () { return this; } }]); }(); function _getDefaultQuery() { return { selector: {} }; } /** * run this query through the QueryCache */ function tunnelQueryCache(rxQuery) { return rxQuery.collection._queryCache.getByQuery(rxQuery); } function createRxQuery(op, queryObj, collection, other) { (0, _hooks.runPluginHooks)('preCreateRxQuery', { op, queryObj, collection, other }); var ret = new RxQueryBase(op, queryObj, collection, other); // ensure when created with same params, only one is created ret = tunnelQueryCache(ret); (0, _queryCache.triggerCacheReplacement)(collection); return ret; } /** * Check if the current results-state is in sync with the database * which means that no write event happened since the last run. * @return false if not which means it should re-execute */ function _isResultsInSync(rxQuery) { var currentLatestEventNumber = rxQuery.asRxQuery.collection._changeEventBuffer.getCounter(); if (rxQuery._latestChangeEvent >= currentLatestEventNumber) { return true; } else { return false; } } /** * wraps __ensureEqual() * to ensure it does not run in parallel * @return true if has changed, false if not * * @performance * Avoid async wrapper when awaitBeforeReads is empty (common case). * This eliminates one unnecessary Promise allocation per query execution. */ function _ensureEqual(rxQuery) { if (rxQuery.collection.awaitBeforeReads.size > 0) { return Promise.all(Array.from(rxQuery.collection.awaitBeforeReads).map(fn => fn())).then(() => { rxQuery._ensureEqualQueue = rxQuery._ensureEqualQueue.then(() => __ensureEqual(rxQuery)); return rxQuery._ensureEqualQueue; }); } rxQuery._ensureEqualQueue = rxQuery._ensureEqualQueue.then(() => __ensureEqual(rxQuery)); return rxQuery._ensureEqualQueue; } /** * ensures that the results of this query is equal to the results which a query over the database would give * @return true if results have changed */ function __ensureEqual(rxQuery) { /** * @performance * Use a counter instead of Date.now() since _lastEnsureEqual * is only used by the cache replacement policy for sorting queries * by last usage and zero-check, not for time-based comparison. */ rxQuery._lastEnsureEqual = ++_ensureEqualCount; /** * Optimisation shortcuts */ if ( // db is closed rxQuery.collection.database.closed || // nothing happened since last run _isResultsInSync(rxQuery)) { return _index.PROMISE_RESOLVE_FALSE; } var ret = false; var mustReExec = false; // if this becomes true, a whole execution over the database is made if (rxQuery._latestChangeEvent === -1) { // have not executed yet -> must run mustReExec = true; } /** * try to use EventReduce to calculate the new results */ if (!mustReExec) { var missedChangeEvents = rxQuery.asRxQuery.collection._changeEventBuffer.getFrom(rxQuery._latestChangeEvent + 1); if (missedChangeEvents === null) { // changeEventBuffer is of bounds -> we must re-execute over the database mustReExec = true; } else { rxQuery._latestChangeEvent = rxQuery.asRxQuery.collection._changeEventBuffer.getCounter(); var runChangeEvents = rxQuery.asRxQuery.collection._changeEventBuffer.reduceByLastOfDoc(missedChangeEvents); if (rxQuery.op === 'count') { // 'count' query var previousCount = (0, _index.ensureNotFalsy)(rxQuery._result).count; var newCount = previousCount; runChangeEvents.forEach(cE => { var didMatchBefore = cE.previousDocumentData && rxQuery.doesDocumentDataMatch(cE.previousDocumentData); var doesMatchNow = rxQuery.doesDocumentDataMatch(cE.documentData); if (!didMatchBefore && doesMatchNow) { newCount++; } if (didMatchBefore && !doesMatchNow) { newCount--; } }); if (newCount !== previousCount) { ret = true; // true because results changed rxQuery._setResultData(newCount); } } else { // 'find' or 'findOne' query var eventReduceResult = (0, _eventReduce.calculateNewResults)(rxQuery, runChangeEvents); if (eventReduceResult.runFullQueryAgain) { // could not calculate the new results, execute must be done mustReExec = true; } else if (eventReduceResult.changed) { // we got the new results, we do not have to re-execute, mustReExec stays false ret = true; // true because results changed rxQuery._setResultData(eventReduceResult.newResults); } } } } // oh no we have to re-execute the whole query over the database if (mustReExec) { return rxQuery._execOverDatabase().then(result => { var newResultData = result.result; /** * The RxStorage is defined to always first emit events and then return * on bulkWrite() calls. So here we have to use the counter AFTER the execOverDatabase() * has been run, not the one from before. */ rxQuery._latestChangeEvent = result.counter; // A count query needs a different has-changed check. if (typeof newResultData === 'number') { if (!rxQuery._result || newResultData !== rxQuery._result.count) { ret = true; rxQuery._setResultData(newResultData); } return ret; } if (!rxQuery._result || !(0, _index.areRxDocumentArraysEqual)(rxQuery.collection.schema.primaryPath, newResultData, rxQuery._result.docsData)) { ret = true; // true because results changed rxQuery._setResultData(newResultData); } return ret; }); } return Promise.resolve(ret); // true if results have changed } /** * Runs the query over the storage instance * of the collection. * Does some optimizations to ensure findById is used * when specific queries are used. */ async function queryCollection(rxQuery) { var docs = []; var collection = rxQuery.collection; /** * Optimizations shortcut. * If query is find-one-document-by-id, * then we do not have to use the slow query() method * but instead can use findDocumentsById() */ if (rxQuery.isFindOneByIdQuery) { var selector = rxQuery.mangoQuery.selector; var primaryPath = collection.schema.primaryPath; // isFindOneByIdQuery guarantees the primary key is in the selector var primarySelectorValue = selector ? selector[primaryPath] : undefined; // Check if there are extra operators on the primary key selector (e.g. $ne alongside $in) var hasExtraOperators = typeof primarySelectorValue === 'object' && primarySelectorValue !== null && Object.keys(primarySelectorValue).length > 1; // Check if there are selectors OTHER than the primary key var hasOtherSelectors = selector ? Object.keys(selector).length > 1 : false; // Normalize single ID to array and de-duplicate to avoid returning the same document multiple times var docIdArray = Array.isArray(rxQuery.isFindOneByIdQuery) ? rxQuery.isFindOneByIdQuery : [rxQuery.isFindOneByIdQuery]; var docIds = Array.from(new Set(docIdArray)); // Separate cache hits from storage misses var cacheMisses = []; docIds.forEach(docId => { var docData = rxQuery.collection._docCache.getLatestDocumentDataIfExists(docId); if (docData && !docData._deleted) { docs.push(docData); } else if (!docData) { // Only fetch from storage if not in cache cacheMisses.push(docId); } // If found but deleted, skip entirely (no refetch) }); // Fetch only cache misses from storage if (cacheMisses.length > 0) { var docsFromStorage = await collection.storageInstance.findDocumentsById(cacheMisses, false); docs = docs.concat(docsFromStorage); } // Apply query matcher if there are extra operators or other selectors if (hasExtraOperators || hasOtherSelectors) { docs = docs.filter(doc => rxQuery.queryMatcher(doc)); } /** * The findDocumentsById() fast-path also does not apply `skip`/`limit`/`sort`. * To keep behavior consistent with storageInstance.query(), we must * apply them after queryMatcher for both find() and findOne() queries. */ // Apply sorting for both find and findOne if (docs.length > 1) { var preparedQuery = rxQuery.getPreparedQuery(); var sortComparator = (0, _rxQueryHelper.getSortComparator)(collection.schema.jsonSchema, preparedQuery.query); docs = docs.sort(sortComparator); } // Apply skip for both find and findOne var skip = typeof rxQuery.mangoQuery.skip === 'number' && rxQuery.mangoQuery.skip > 0 ? rxQuery.mangoQuery.skip : 0; if (skip > 0) { docs = docs.slice(skip); } // Apply limit for both find and findOne var limitIsNumber = typeof rxQuery.mangoQuery.limit === 'number' && rxQuery.mangoQuery.limit > 0; if (limitIsNumber) { var limit = rxQuery.mangoQuery.limit; docs = docs.slice(0, limit); } } else { var _preparedQuery = rxQuery.getPreparedQuery(); var queryResult = await collection.storageInstance.query(_preparedQuery); docs = queryResult.documents; } return { docs, counter: collection._changeEventBuffer.getCounter() }; } /** * Returns true if the given query * selects documents by primary key using $eq or $in. * Used to optimize performance: these queries use get-by-id * instead of a full index scan. Additional operators beyond * $eq/$in are handled via the queryMatcher after fetching. * Skip, limit, and sort are also applied after fetching. * Returns false if no such optimization is possible. * Returns the document id (string) or ids (string[]) otherwise. */ function isFindOneByIdQuery(primaryPath, query) { // primary key constraint can coexist with other selectors, skip, limit, and sort // The optimization will fetch by ID, then apply queryMatcher, sort, skip, and limit // Use hasOwnProperty to avoid prototype pollution from user-controlled input if (query.selector && Object.prototype.hasOwnProperty.call(query.selector, primaryPath)) { var value = query.selector[primaryPath]; if (typeof value === 'string') { return value; } else if (typeof value.$eq === 'string') { return value.$eq; } // same with $in string arrays if (Array.isArray(value.$in) && // must only contain strings !value.$in.find(r => typeof r !== 'string')) { return value.$in; } } return false; } function isRxQuery(obj) { return obj instanceof RxQueryBase; } //# sourceMappingURL=rx-query.js.map