UNPKG

test-rxdb

Version:

A local realtime NoSQL Database for JavaScript applications -

803 lines (722 loc) 26.9 kB
import { BehaviorSubject, Observable, merge } from 'rxjs'; import { mergeMap, filter, map, startWith, distinctUntilChanged, shareReplay } from 'rxjs/operators'; import { sortObject, pluginMissing, overwriteGetterForCaching, now, PROMISE_RESOLVE_FALSE, RXJS_SHARE_REPLAY_DEFAULTS, ensureNotFalsy, areRxDocumentArraysEqual, appendToArray } from './plugins/utils/index.ts'; import { newRxError } from './rx-error.ts'; import { runPluginHooks } from './hooks.ts'; import type { RxCollection, RxDocument, RxQueryOP, RxQuery, MangoQuery, MangoQuerySortPart, MangoQuerySelector, PreparedQuery, RxChangeEvent, RxDocumentWriteData, RxDocumentData, QueryMatcher, RxJsonSchema, FilledMangoQuery, ModifyFunction } from './types/index.d.ts'; import { calculateNewResults } from './event-reduce.ts'; import { triggerCacheReplacement } from './query-cache.ts'; import { getQueryMatcher, normalizeMangoQuery, runQueryUpdateFunction } from './rx-query-helper.ts'; import { RxQuerySingleResult } from './rx-query-single-result.ts'; import { getQueryPlan } from './query-planner.ts'; let _queryCount = 0; const newQueryID = function (): number { return ++_queryCount; }; export class RxQueryBase< RxDocType, RxQueryResult, OrmMethods = {}, Reactivity = unknown, > { public id: number = newQueryID(); /** * Some stats then are used for debugging and cache replacement policies */ public _execOverDatabaseCount: number = 0; public _creationTime = now(); // used in the query-cache to determine if the RxQuery can be cleaned up. public _lastEnsureEqual = 0; public uncached = false; // used to count the subscribers to the query public refCount$ = new BehaviorSubject(null); public isFindOneByIdQuery: false | string | string[]; /** * Contains the current result state * or null if query has not run yet. */ public _result: RxQuerySingleResult<RxDocType> | null = null; constructor( public op: RxQueryOP, public mangoQuery: Readonly<MangoQuery<RxDocType>>, public collection: RxCollection<RxDocType>, // used by some plugins public other: any = {} ) { if (!mangoQuery) { this.mangoQuery = _getDefaultQuery(); } this.isFindOneByIdQuery = isFindOneByIdQuery( this.collection.schema.primaryPath as string, mangoQuery ); } get $(): Observable<RxQueryResult> { if (!this._$) { const results$ = this.collection.$.pipe( /** * Performance shortcut. * Changes to local documents are not relevant for the query. */ filter(changeEvent => !changeEvent.isLocal), /** * Start once to ensure the querying also starts * when there where no changes. */ startWith(null), // ensure query results are up to date. mergeMap(() => _ensureEqual(this as any)), // use the current result set, written by _ensureEqual(). map(() => this._result), // do not run stuff above for each new subscriber, only once. shareReplay(RXJS_SHARE_REPLAY_DEFAULTS), // do not proceed if result set has not changed. distinctUntilChanged((prev, curr) => { if (prev && prev.time === ensureNotFalsy(curr).time) { return true; } else { return false; } }), filter(result => !!result), /** * Map the result set to a single RxDocument or an array, * depending on query type */ map((result) => { return ensureNotFalsy(result).getValue(); }) ); this._$ = merge<any>( results$, /** * Also add the refCount$ to the query observable * to allow us to count the amount of subscribers. */ this.refCount$.pipe( filter(() => false) ) ); } return this._$ as any; } get $$(): Reactivity { const reactivity = this.collection.database.getReactivityFactory(); return reactivity.fromObservable( this.$, undefined, this.collection.database ) as any; } // stores the changeEvent-number of the last handled change-event public _latestChangeEvent: -1 | number = -1; // time stamps on when the last full exec over the database has run // used to properly handle events that happen while the find-query is running // TODO do we still need these properties? public _lastExecStart: number = 0; public _lastExecEnd: number = 0; /** * ensures that the exec-runs * are not run in parallel */ public _ensureEqualQueue: Promise<boolean> = PROMISE_RESOLVE_FALSE; /** * 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) */ public _$?: Observable<RxQueryResult>; /** * set the new result-data as result-docs of the query * @param newResultData json-docs that were received from the storage */ _setResultData(newResultData: RxDocumentData<RxDocType>[] | number | Map<string, RxDocumentData<RxDocType>>): void { if (typeof newResultData === 'undefined') { throw newRxError('QU18', { database: this.collection.database.name, collection: this.collection.name }); } if (typeof newResultData === 'number') { this._result = new RxQuerySingleResult<RxDocType>( this as any, [], newResultData ); return; } else if (newResultData instanceof Map) { newResultData = Array.from((newResultData as Map<string, RxDocumentData<RxDocType>>).values()); } const newQueryResult = new RxQuerySingleResult<RxDocType>( this as any, newResultData, newResultData.length ); this._result = newQueryResult; } /** * executes the query on the database * @return results-array with document-data */ async _execOverDatabase(): Promise<RxDocumentData<RxDocType>[] | number> { this._execOverDatabaseCount = this._execOverDatabaseCount + 1; this._lastExecStart = now(); if (this.op === 'count') { const preparedQuery = this.getPreparedQuery(); const result = await this.collection.storageInstance.count(preparedQuery); if (result.mode === 'slow' && !this.collection.database.allowSlowCount) { throw newRxError('QU14', { collection: this.collection, queryObj: this.mangoQuery }); } else { return result.count; } } if (this.op === 'findByIds') { const ids: string[] = ensureNotFalsy(this.mangoQuery.selector as any)[this.collection.schema.primaryPath].$in; const ret = new Map<string, RxDocument<RxDocType>>(); const mustBeQueried: string[] = []; // first try to fill from docCache ids.forEach(id => { const docData = this.collection._docCache.getLatestDocumentDataIfExists(id); if (docData) { if (!docData._deleted) { const doc = this.collection._docCache.getCachedRxDocument(docData); ret.set(id, doc); } } else { mustBeQueried.push(id); } }); // everything which was not in docCache must be fetched from the storage if (mustBeQueried.length > 0) { const docs = await this.collection.storageInstance.findDocumentsById(mustBeQueried, false); docs.forEach(docData => { const doc = this.collection._docCache.getCachedRxDocument(docData); ret.set(doc.primary, doc); }); } return ret as any; } const docsPromise = queryCollection<RxDocType>(this as any); return docsPromise.then(docs => { this._lastExecEnd = now(); return docs; }); } /** * Execute the query * To have an easier implementations, * just subscribe and use the first result */ public exec(throwIfMissing: true): Promise<RxDocument<RxDocType, OrmMethods, Reactivity>>; public exec(): Promise<RxQueryResult>; public async exec(throwIfMissing?: boolean): Promise<any> { if (throwIfMissing && this.op !== 'findOne') { throw 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 as any); const useResult = ensureNotFalsy(this._result); return useResult.getValue(throwIfMissing); } /** * cached call to get the queryMatcher * @overwrites itself with the actual value */ get queryMatcher(): QueryMatcher<RxDocumentWriteData<RxDocType>> { const schema = this.collection.schema.jsonSchema; const normalizedQuery = normalizeMangoQuery( this.collection.schema.jsonSchema, this.mangoQuery ); return overwriteGetterForCaching( this, 'queryMatcher', getQueryMatcher( schema, normalizedQuery ) as any ); } /** * returns a string that is used for equal-comparisons * @overwrites itself with the actual value */ toString(): string { const stringObj = sortObject({ op: this.op, query: this.mangoQuery, other: this.other }, true); const value = JSON.stringify(stringObj); this.toString = () => value; return value; } /** * returns the prepared query * which can be send to the storage instance to query for documents. * @overwrites itself with the actual value. */ getPreparedQuery(): PreparedQuery<RxDocType> { const hookInput = { rxQuery: this, // can be mutated by the hooks so we have to deep clone first. mangoQuery: normalizeMangoQuery<RxDocType>( this.collection.schema.jsonSchema, this.mangoQuery ) }; (hookInput.mangoQuery.selector as any)._deleted = { $eq: false }; if (hookInput.mangoQuery.index) { hookInput.mangoQuery.index.unshift('_deleted'); } runPluginHooks('prePrepareQuery', hookInput); const value = prepareQuery( this.collection.schema.jsonSchema, hookInput.mangoQuery as any ); this.getPreparedQuery = () => value; return value; } /** * returns true if the document matches the query, * does not use the 'skip' and 'limit' */ doesDocumentDataMatch(docData: RxDocType | any): boolean { // 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 */ remove(): Promise<RxQueryResult> { return this .exec() .then(docs => { if (Array.isArray(docs)) { // TODO use a bulk operation instead of running .remove() on each document return Promise.all(docs.map(doc => doc.remove())); } else { return (docs as any).remove(); } }); } incrementalRemove(): Promise<RxQueryResult> { return runQueryUpdateFunction( this.asRxQuery, (doc) => doc.incrementalRemove(), ); } /** * helper function to transform RxQueryBase to RxQuery type */ get asRxQuery(): RxQuery<RxDocType, RxQueryResult> { return this as any; } /** * updates all found documents * @overwritten by plugin (optional) */ update(_updateObj: any): Promise<RxQueryResult> { throw pluginMissing('update'); } patch(patch: Partial<RxDocType>): Promise<RxQueryResult> { return runQueryUpdateFunction( this.asRxQuery, (doc) => doc.patch(patch), ); } incrementalPatch(patch: Partial<RxDocType>): Promise<RxQueryResult> { return runQueryUpdateFunction( this.asRxQuery, (doc) => doc.incrementalPatch(patch), ); } modify(mutationFunction: ModifyFunction<RxDocType>): Promise<RxQueryResult> { return runQueryUpdateFunction( this.asRxQuery, (doc) => doc.modify(mutationFunction), ); } incrementalModify(mutationFunction: ModifyFunction<RxDocType>): Promise<RxQueryResult> { return runQueryUpdateFunction( this.asRxQuery, (doc) => doc.incrementalModify(mutationFunction), ); } // we only set some methods of query-builder here // because the others depend on these ones where(_queryObj: MangoQuerySelector<RxDocType> | keyof RxDocType | string): RxQuery<RxDocType, RxQueryResult> { throw pluginMissing('query-builder'); } sort(_params: string | MangoQuerySortPart<RxDocType>): RxQuery<RxDocType, RxQueryResult> { throw pluginMissing('query-builder'); } skip(_amount: number | null): RxQuery<RxDocType, RxQueryResult> { throw pluginMissing('query-builder'); } limit(_amount: number | null): RxQuery<RxDocType, RxQueryResult> { throw pluginMissing('query-builder'); } } export function _getDefaultQuery<RxDocType>(): MangoQuery<RxDocType> { return { selector: {} }; } /** * run this query through the QueryCache */ export function tunnelQueryCache<RxDocumentType, RxQueryResult>( rxQuery: RxQueryBase<RxDocumentType, RxQueryResult> ): RxQuery<RxDocumentType, RxQueryResult> { return rxQuery.collection._queryCache.getByQuery(rxQuery as any); } export function createRxQuery<RxDocType>( op: RxQueryOP, queryObj: MangoQuery<RxDocType>, collection: RxCollection<RxDocType>, other?: any ) { runPluginHooks('preCreateRxQuery', { op, queryObj, collection, other }); let ret = new RxQueryBase<RxDocType, any>(op, queryObj, collection, other); // ensure when created with same params, only one is created ret = tunnelQueryCache(ret); 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: RxQueryBase<any, any>): boolean { const 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 */ async function _ensureEqual(rxQuery: RxQueryBase<any, any>): Promise<boolean> { if (rxQuery.collection.awaitBeforeReads.size > 0) { await Promise.all(Array.from(rxQuery.collection.awaitBeforeReads).map(fn => fn())); } // Optimisation shortcut if ( rxQuery.collection.database.destroyed || _isResultsInSync(rxQuery) ) { return false; } 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<RxDocType>(rxQuery: RxQueryBase<RxDocType, any>): Promise<boolean> { rxQuery._lastEnsureEqual = now(); /** * Optimisation shortcuts */ if ( // db is closed rxQuery.collection.database.destroyed || // nothing happened since last run _isResultsInSync(rxQuery) ) { return PROMISE_RESOLVE_FALSE; } let ret = false; let 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) { const 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(); const runChangeEvents: RxChangeEvent<RxDocType>[] = rxQuery.asRxQuery.collection ._changeEventBuffer .reduceByLastOfDoc(missedChangeEvents); if (rxQuery.op === 'count') { // 'count' query const previousCount = ensureNotFalsy(rxQuery._result).count; let newCount = previousCount; runChangeEvents.forEach(cE => { const didMatchBefore = cE.previousDocumentData && rxQuery.doesDocumentDataMatch(cE.previousDocumentData); const 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 as any); } } else { // 'find' or 'findOne' query const eventReduceResult = calculateNewResults( rxQuery as any, 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 as any); } } } } // oh no we have to re-execute the whole query over the database if (mustReExec) { return rxQuery._execOverDatabase() .then(newResultData => { /** * 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 = rxQuery.collection._changeEventBuffer.getCounter(); // 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 as any); } return ret; } if ( !rxQuery._result || !areRxDocumentArraysEqual( rxQuery.collection.schema.primaryPath, newResultData, rxQuery._result.docsData ) ) { ret = true; // true because results changed rxQuery._setResultData(newResultData as any); } return ret; }); } return Promise.resolve(ret); // true if results have changed } /** * @returns a format of the query that can be used with the storage * when calling RxStorageInstance().query() */ export function prepareQuery<RxDocType>( schema: RxJsonSchema<RxDocumentData<RxDocType>>, mutateableQuery: FilledMangoQuery<RxDocType> ): PreparedQuery<RxDocType> { if (!mutateableQuery.sort) { throw newRxError('SNH', { query: mutateableQuery }); } /** * Store the query plan together with the * prepared query to save performance. */ const queryPlan = getQueryPlan( schema, mutateableQuery ); return { query: mutateableQuery, queryPlan }; } /** * Runs the query over the storage instance * of the collection. * Does some optimizations to ensure findById is used * when specific queries are used. */ export async function queryCollection<RxDocType>( rxQuery: RxQuery<RxDocType> | RxQueryBase<RxDocType, any> ): Promise<RxDocumentData<RxDocType>[]> { let docs: RxDocumentData<RxDocType>[] = []; const 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) { if (Array.isArray(rxQuery.isFindOneByIdQuery)) { let docIds = rxQuery.isFindOneByIdQuery; docIds = docIds.filter(docId => { // first try to fill from docCache const docData = rxQuery.collection._docCache.getLatestDocumentDataIfExists(docId); if (docData) { if (!docData._deleted) { docs.push(docData); } return false; } else { return true; } }); // otherwise get from storage if (docIds.length > 0) { const docsFromStorage = await collection.storageInstance.findDocumentsById(docIds, false); appendToArray(docs, docsFromStorage); } } else { const docId = rxQuery.isFindOneByIdQuery; // first try to fill from docCache let docData = rxQuery.collection._docCache.getLatestDocumentDataIfExists(docId); if (!docData) { // otherwise get from storage const fromStorageList = await collection.storageInstance.findDocumentsById([docId], false); if (fromStorageList[0]) { docData = fromStorageList[0]; } } if (docData && !docData._deleted) { docs.push(docData); } } } else { const preparedQuery = rxQuery.getPreparedQuery(); const queryResult = await collection.storageInstance.query(preparedQuery); docs = queryResult.documents; } return docs; } /** * Returns true if the given query * selects exactly one document by its id. * Used to optimize performance because these kind of * queries do not have to run over an index and can use get-by-id instead. * Returns false if no query of that kind. * Returns the document id otherwise. */ export function isFindOneByIdQuery( primaryPath: string, query: MangoQuery<any> ): false | string | string[] { // must have exactly one operator which must be $eq || $in if ( !query.skip && query.selector && Object.keys(query.selector).length === 1 && query.selector[primaryPath] ) { const value: any = query.selector[primaryPath]; if (typeof value === 'string') { return value; } else if ( Object.keys(value).length === 1 && typeof value.$eq === 'string' ) { return value.$eq; } // same with $in string arrays if ( Object.keys(value).length === 1 && Array.isArray(value.$eq) && // must only contain strings !(value.$eq as any[]).find(r => typeof r !== 'string') ) { return value.$eq; } } return false; } export function isRxQuery(obj: any): boolean { return obj instanceof RxQueryBase; }