Home Reference Source

lib/Transaction.js

import {tokenize, splitQuery, splitWords, fastMap, flatten, dedup} from './utils';
import {NoSuchColumnError, InvalidKeyError} from './errors';
import IFTSArrayPromise from './ArrayPromise';


/**
 * Transaction.
 *
 * Almost methods are the same interface as {@link IndexedFTS} and {@link IFTSArrayPromise}.
 * Probably this class is faster than other classes in most cases.
 *
 * Please be careful, IFTSTransaction are sometimes makes a big cache.
 * Should not keep many transactions if not need.
 */
export default class IFTSTransaction {
	/**
	 * @param {IndexedFTS} db - database.
	 * @param {IDBTransaction} transaction - transaction of IndexedDB.
	 */
	constructor(db, transaction) {
		/** @type {IndexedDB} */
		this.db = db;

		/** @type {IDBTransaction} */
		this.transaction = transaction;

		/** @ignore */
		this._KeyRange = this.db.scope.IDBKeyRange;

		/**
		 * Promise for await closing transaction.
		 *
		 * @type {Promise<IndexedDB>}
		 */
		this.promise = new Promise((resolve, reject) => {
			this.transaction.oncomplete = () => resolve(this.db);
			this.transaction.onerror = err => reject(err);
		});

		/** @ignore */
		this._cache = {};
	}

	/**
	 * Put contents into database.
	 *
	 * @param {object} contents - contents for save. allowed multiple arguments.
	 *
	 * @return {Promise<IFTSTransaction>} returns self for chain.
	 */
	put(...contents) {
		const store = this.transaction.objectStore('data');
		const ngram_indexes = fastMap([...this.db.schema.ngramIndexes], column => ({name: column, store: this.transaction.objectStore(this.db.index_prefix + 'ngram_' + column)}));
		const word_indexes = fastMap([...this.db.schema.wordIndexes], column => ({name: column, store: this.transaction.objectStore(this.db.index_prefix + 'word_' + column)}));

		const putPromises = new Array(contents.length);
		for (let i=0; i<contents.length; i++) {
			putPromises[i] = new Promise((resolve, reject) => {
				const req = store.put(contents[i]);
				req.onerror = reject;
				req.onsuccess = ev => {
					resolve(
						this._updateNGramIndex(ev.target.result, contents[i], ngram_indexes)
							.then(() => this._updateWordIndex(ev.target.result, contents[i], word_indexes)))
				};
			});
		}

		return Promise.all(putPromises).then(data => {
			for (let i=0; i<data.length; i++) {
				const key = data[i][0];
				const value = data[i][1];
				if (this.db.schema.primaryKey === null) {
					value._key = key;
				}
				this._cache[key] = value;
			}
			return this;
		});
	}

	/**
	 * Update ngram index.
	 *
	 * @ignore
	 */
	_updateNGramIndex(key, data, ngram_indexes) {
		return this._deleteIndex(key, ngram_indexes.map(x => this.db.index_prefix + 'ngram_' + x.name))
			.then(() => Promise.all(fastMap(ngram_indexes, col => {
				const tokens = tokenize(data[col.name]);
				const promises = new Array(tokens.length);
				for (let i=0; i<tokens.length; i++) {
					promises[i] = new Promise((resolve, reject) => {
						const req = col.store.put({
							key: key,
							token: tokens[i],
							lower: tokens[i].toLowerCase(),
						});
						req.onsuccess = () => resolve();
						req.onerror = reject;
					});
				}
				return Promise.all(promises);
			})))
			.then(() => [key, data]);
	}

	/**
	 * Update word index.
	 *
	 * @ignore
	 */
	_updateWordIndex(key, data, word_indexes) {
		return this._deleteIndex(key, word_indexes.map(x => this.db.index_prefix + 'word_' + x.name))
			.then(() => Promise.all(fastMap(word_indexes, col => {
				const words = splitWords(data[col.name]);
				const promises = new Array(words.length);
				for (let i=0; i<words.length; i++) {
					promises[i] = new Promise((resolve, reject) => {
						const req = col.store.put({
							key: key,
							word: words[i],
							lower: words[i].toLowerCase(),
						});
						req.onsuccess = () => resolve();
						req.onerror = reject;
					});
				}
				return Promise.all(promises);
			})))
			.then(() => [key, data]);
	}

	/**
	 * Delete content by FTS indexes of database.
	 *
	 * @ignore
	 */
	_deleteIndex(key, tableNames) {
		return Promise.all(tableNames.map(table => {
			return new Promise((resolve, reject) => {
				const store = this.transaction.objectStore(table);
				store.onerror = reject;

				const requests = [];

				const req = store.index('key').openKeyCursor(this._KeyRange.only(key));
				req.onerror = reject;
				req.onsuccess = ev => {
					const cursor = ev.target.result;
					if (cursor) {
						requests.push(new Promise((resolve, reject) => {
							const d = store.delete(cursor.primaryKey);
							d.onsuccess = resolve;
							d.onerror = reject
						}));
						cursor.continue();
					} else {
						resolve(Promise.all(requests));
					}
				}
			});
		}));
	}

	/**
	 * Delete contents from database.
	 *
	 * @param {object} keys - key of contents. allowed multiple arguments.
	 *
	 * @return {Promise<IFTSTransaction>} returns self for chain. Will reject with {@link InvalidKeyError} if keys included null or undefined.
	 */
	delete(...keys) {
		for (let i=0; i<keys.length; i++) {
			if (keys[i] === null || keys[i] === undefined) {
				return Promise.reject(new InvalidKeyError(keys[i]));
			}
		}

		return Promise.all(fastMap(keys, key => {
			return new Promise((resolve, reject) => {
				const req = this.transaction.objectStore('data').delete(key);
				req.onerror = reject;
				req.onsuccess = resolve;
			})
			.then(() => this._deleteIndex(key, [
				...[...this.db.schema.ngramIndexes].map(x => this.db.index_prefix + 'ngram_' + x),
				...[...this.db.schema.wordIndexes].map(x => this.db.index_prefix + 'word_' + x),
			]))
		})).then(() => this);
	}

	/**
	 * Make {@link IFTSArrayPromise} by cursor.
	 *
	 * @ignore
	 */
	_readCursor(cursorRequest, filter=null, map=null, limit=undefined) {
		filter = filter || ((x, i) => true);
		map = map || ((x, i) => x);

		return new IFTSArrayPromise(this.db.schema.indexes, new Promise((resolve, reject) => {
			const result = [];
			let index = 0;

			cursorRequest.onsuccess = ev => {
				const cursor = ev.target.result;
				if (cursor) {
					const value = cursor.value;
					if (this.db.schema.primaryKey === null) {
						value._key = cursor.key;
					}
					this._cache[cursor.key] = value;
					if (filter(value, index)) {
						result.push(map(value, index));
					}

					index++;
					if (limit === undefined || index < limit) {
						cursor.continue();
					} else {
						resolve(result);
					}
				} else {
					resolve(result);
				}
			};
			cursorRequest.onerror = err => reject(err);
		}));
	}

	/**
	 * Get all contents.
	 *
	 * @return {IFTSArrayPromise} contents.
	 */
	getAll() {
		return this._readCursor(this.transaction.objectStore('data').openCursor());
	}

	/**
	 * Get all contents with primary keys.
	 *
	 * @ignore
	 */
	_getAllWithKeys() {
		return new IFTSArrayPromise(this.db.schema.indexes, new Promise((resolve, reject) => {
			const request = this.transaction.objectStore('data').openCursor();

			const result = [];
			request.onsuccess = ev => {
				const cursor = ev.target.result;
				if (cursor) {
					const value = cursor.value;
					if (this.db.schema.primaryKey === null) {
						value._key = cursor.key;
					}
					this._cache[cursor.key] = value;
					result.push({key: cursor.key, data: value});
					cursor.continue();
				} else {
					resolve(result);
				}
			};

			request.onerror = err => reject(err);
		}));
	}

	/**
	 * Do something process for each elements and returns {@link IFTSArrayPromise}.
	 *
	 * NOTE: This method doesn't fast. May better do filtering before doing map if need filtering.
	 *
	 * @param {function(content: object, index: Number): object} fun - function for processing element.
	 *
	 * @return {IFTSArrayPromise}
	 */
	map(fun) {
		return this._readCursor(this.transaction.objectStore('data').openCursor(null), null, fun);
	}

	/**
	 * Filtering elements by function and returns {@link IFTSArrayPromise}.
	 *
	 * WARNING: This method won't use the index. Other methods(eg. {@link IFTSTransaction#equals} or {@link IFTSTransaction#lower} may faster than this.
	 *
	 * @param {function(content: object, index: Number): object} fun - function for filtering element.
	 *
	 * @return {IFTSArrayPromise}
	 */
	filter(fun) {
		return this._readCursor(this.transaction.objectStore('data').openCursor(null), fun, null);
	}

	/**
	 * Sort and get all contents.
	 *
	 * @param {object} column - the column for sorting.
	 * @param {'asc'|'desc'} [order='asc'] - sort order.
	 * @param {Number} [offset=0] - starting offset of the result.
	 * @param {Number} [limit] - maximum number of result length. will unlimited if omitted.
	 *
	 * @return {IFTSArrayPromise} sorted contents.
	 */
	sort(column, order='asc', offset=0, limit=undefined) {
		if (!this.db.schema.indexes.has(column)) {
			return IFTSArrayPromise.reject(this.db.schema.indexes, new NoSuchColumnError(column));
		}

		limit = limit === undefined ? undefined : offset + limit;
		const offsetFilter = (x, i) => offset <= i;

		const store = this.transaction.objectStore('data');

		if (column === this.db.schema.primaryKey) {
			return this._readCursor(store.openCursor(null, order === 'desc' ? 'prev' : 'next'), offsetFilter, null, limit);
		} else {
			return this._readCursor(store.index(column).openCursor(null, order === 'desc' ? 'prev' : 'next'), offsetFilter, null, limit);
		}
	}

	/**
	 * Get content by primary key.
	 *
	 * @param {object} key - the key of content.
	 *
	 * @return {Promise<object|undefined>} content. promise will reject with {@link InvalidKeyError} if keys included null or undefined. result value will be undefined if not found.
	 */
	get(key) {
		if (key === null || key === undefined) {
			return Promise.reject(new InvalidKeyError(key));
		}
		if (key in this._cache) {
			return Promise.resolve(this._cache[key]);
		}
		return new Promise((resolve, reject) => {
			const req = this.transaction.objectStore('data').get(key);
			req.onsuccess = ev => {
				const value = ev.target.result;
				if (this.db.schema.primaryKey === null) {
					value._key = key;
				}
				this._cache[key] = value;
				resolve(value);
			};
			req.onerror = reject;
		});
	}

	/**
	 * Get contents matched keyRange.
	 *
	 * @ignore
	 */
	_getAllWithIndex(column, keyRange) {
		if (!this.db.schema.indexes.has(column)) {
			return IFTSArrayPromise.reject(this.db.schema.indexes, new NoSuchColumnError(column));
		}

		const store = this.transaction.objectStore('data');

		if (column === this.db.schema.primaryKey) {
			return this._readCursor(store.openCursor(keyRange));
		} else {
			return this._readCursor(store.index(column).openCursor(keyRange));
		}
	}

	/**
	 * Get contents that have fully matched property.
	 *
	 * @param {object} column - column name for search.
	 * @param {object} value - value for search.
	 *
	 * @return {IFTSArrayPromise} matched contents. may reject with {@link NoSuchColumnError}.
	 */
	equals(column, value) {
		return this._getAllWithIndex(column, this._KeyRange.only(value));
	}

	/**
	 * Get contents that have property lower than value.
	 *
	 * @param {object} column - column name for search.
	 * @param {object} value - value for search.
	 *
	 * @return {IFTSArrayPromise} matched contents. may reject with {@link NoSuchColumnError}.
	 */
	lower(column, value) {
		return this._getAllWithIndex(column, this._KeyRange.upperBound(value, true));
	}

	/**
	 * Get contents that have property greater than value.
	 *
	 * @param {object} column - column name for search.
	 * @param {object} value - value for search.
	 *
	 * @return {IFTSArrayPromise} matched contents. may reject with {@link NoSuchColumnError}.
	 */
	greater(column, value) {
		return this._getAllWithIndex(column, this._KeyRange.lowerBound(value, true));
	}

	/**
	 * Get contents that have property lower than value or equals value.
	 *
	 * @param {object} column - column name for search.
	 * @param {object} value - value for search.
	 *
	 * @return {IFTSArrayPromise} matched contents. may reject with {@link NoSuchColumnError}.
	 */
	lowerOrEquals(column, value) {
		return this._getAllWithIndex(column, this._KeyRange.upperBound(value, false));
	}

	/**
	 * Get contents that have property greater than value or equals value.
	 *
	 * @param {object} column - column name for search.
	 * @param {object} value - value for search.
	 *
	 * @return {IFTSArrayPromise} matched contents. may reject with {@link NoSuchColumnError}.
	 */
	greaterOrEquals(column, value) {
		return this._getAllWithIndex(column, this._KeyRange.lowerBound(value, false));
	}

	/**
	 * Get contents that have property is between argument values.
	 *
	 * @param {object} column - column name for search.
	 * @param {object} lower - minimal value.
	 * @param {object} upper - maximum value.
	 *
	 * @return {IFTSArrayPromise} matched contents. may reject with {@link NoSuchColumnError}.
	 */
	between(column, lower, upper) {
		return this._getAllWithIndex(column, this._KeyRange.bound(lower, upper, false, false));
	}

	/**
	 * Get candidates of search result.
	 *
	 * @ignore
	 */
	_takeCandidatesBySingleColumn(column, queries, options={}) {
		const store = this.transaction.objectStore(this.db.index_prefix + 'ngram_' + column);
		const index = options.ignoreCase ? store.index('lower') : store.index('token');
		const result = [];

		for (let q in queries) {
			const checkIncludes = (
				options.ignoreCase
					? (x => x.data[column].toLowerCase().includes(q))
					: (x => x.data[column].includes(q))
			);

			if (queries[q].length === 0) {
				result.push(this._getAllWithKeys().filter(checkIncludes).map(x => x.key).then(xs => ({query: q, keys: xs})));
				continue;
			}

			const promises = new Array(queries[q].length);
			for (let i=0; i<queries[q].length; i++) {
				promises[i] = this._readCursor(index.openCursor(queries[q][i]), null, data => data.key);
			}

			const candidate = Promise.all(promises)
				.then(founds => {
					if (founds.length === 0) {
						return Promise.resolve([]);
					}

					founds = flatten(founds);

					const deduped = new Array(founds.length);
					let dedup_num = 0;
					const hit_count = {};
					for (let i=0; i<founds.length; i++) {
						if (!(founds[i] in hit_count)) {
							hit_count[founds[i]] = 0;

							deduped[dedup_num] = founds[i];
							dedup_num++;
						}
						hit_count[founds[i]]++;
					}

					const candidates = new Array(dedup_num);
					let candidate_num = 0;
					for (let i=0; i<dedup_num; i++) {
						if (hit_count[deduped[i]] >= queries[q].length) {
							candidates[candidate_num] = this.get(deduped[i]).then(data => ({key: deduped[i], data: data}));
							candidate_num++;
						}
					}
					return Promise.all(candidates.slice(0, candidate_num));
				})
				.then(xs => ({query: q, keys: xs.filter(checkIncludes).map(x => x.key)}))

			result.push(candidate);
		}

		return result;
	}

	/**
	 * Prune contents by result of {@link IFTSTransaction#_takeCandidatesBySingleColumn}.
	 *
	 * @ignore
	 */
	async _pruneCandidates(queries_num, candidates) {
		const keys = {};

		for (let i=0; i<candidates.length; i++) {
			for (let j=0; j<candidates[i].keys.length; j++) {
				if (!(candidates[i].keys[j] in keys)) {
					keys[candidates[i].keys[j]] = new Set();
				}
				keys[candidates[i].keys[j]].add(candidates[i].query);
			}
		}

		const result = new Array(candidates.length);
		let result_num = 0;
		for (let key in keys) {
			if (keys[key].size == queries_num) {
				result[result_num] = this.get(key);
				result_num++;
			}
		}

		return await Promise.all(result.slice(0, result_num));
	}

	/**
	 * Get contents that have matched property by full-text search.
	 *
	 * All target columns have to made ngram index when created database.
	 * If you didn't made ngram index, you can use {@link IFTSArrayPromise#search} (but this way is very slow).
	 *
	 *
	 * @param {object|object[]} columns - column names for search.
	 * @param {string} query - query for search.
	 * @param {object} [options] - optional arguments.
	 * @param {boolean} [options.ignoreCase=false] - ignore case if true. default is false.
	 *
	 * @return {IFTSArrayPromise} matched contents. may reject with {@link NoSuchColumnError}.
	 */
	search(columns, query, options={}) {
		if (typeof columns === 'string') {
			columns = [columns];
		}

		for (let i=0; i<columns.length; i++) {
			if (!this.db.schema.ngramIndexes.has(columns[i])) {
				return IFTSArrayPromise.reject(this.db.schema.indexes, new NoSuchColumnError(columns[i]));
			}
		}

		query = options.ignoreCase ? query.toLowerCase() : query;
		const queries = splitQuery(query);
		let queries_length = 0;

		for (let q in queries) {
			queries[q] = fastMap(queries[q], x => this._KeyRange.only(x));
			queries_length++;
		}

		const candidatePromises = [];

		for (let i=0; i<columns.length; i++) {
			Array.prototype.push.apply(candidatePromises, this._takeCandidatesBySingleColumn(columns[i], queries, options));
		}

		return new IFTSArrayPromise(
			this.db.schema.indexes,
			Promise.all(candidatePromises).then(xs => this._pruneCandidates(queries_length, xs)),
		);
	}

	/**
	 * Find contents that have fully matched word in property.
	 *
	 * All target columns have to made word index when created database.
	 * If you didn't made word index, you can use {@link IFTSArrayPromise#searchWord} (but this way is very slow).
	 *
	 *
	 * @param {object|object[]} columns - column names for search.
	 * @param {string} query - query for search.
	 * @param {object} [options] - optional arguments.
	 * @param {boolean} [options.ignoreCase=false] - ignore case if true. default is false.
	 *
	 * @return {IFTSArrayPromise} matched contents. may reject with {@link NoSuchColumnError}.
	 */
	searchWord(columns, query, options={}) {
		if (typeof columns === 'string') {
			columns = [columns];
		}

		for (let i=0; i<columns.length; i++) {
			if (!this.db.schema.wordIndexes.has(columns[i])) {
				return IFTSArrayPromise.reject(this.db.schema.indexes, new NoSuchColumnError(columns[i]));
			}
		}

		query = options.ignoreCase ? query.toLowerCase() : query;
		const queries = splitWords(query).map(x => ({text: x, keyRange: this._KeyRange.only(x)}));

		return new IFTSArrayPromise(this.db.schema.indexes, Promise.all(flatten(columns.map(col => {
			const store = this.transaction.objectStore(this.db.index_prefix + 'word_' + col);
			const index = options.ignoreCase ? store.index('lower') : store.index('word');

			return queries.map(query => this._readCursor(index.openCursor(query.keyRange), null, data => [data.key, query.text]));
		}))).then(candidates => {
			candidates = dedup(flatten(candidates));

			const counts = {};
			for (let i=0; i<candidates.length; i++) {
				const key = candidates[i][0];
				if (!(key in counts)) {
					counts[key] = 0;
				}
				counts[key]++;
			}

			const hits = new Array(candidates.length);
			let hits_count = 0;
			for (let i=0; i<candidates.length; i++) {
				const key = candidates[i][0];
				if (counts[key] >= queries.length) {
					hits[hits_count] = key;
					hits_count++;
				}
			}

			const result = new Array(hits_count);
			for (let i=0; i<hits_count; i++) {
				result[i] = this.get(hits[i]);
			}
			return new IFTSArrayPromise(this.db.schema.indexes, Promise.all(result));
		}));
	}

	/**
	 * Make token set from index.
	 *
	 * @ignore
	 */
	_readIndexSet(index) {
		const result = new Map();

		return new Promise((resolve, reject) => {
			const cursor = index.openKeyCursor();

			cursor.onsuccess = ev => {
				const cursor = ev.target.result;
				if (cursor) {
					result.set(cursor.key, (result.get(cursor.key) || 0) + 1);
					cursor.continue();
				} else {
					resolve(result);
				}
			}
			cursor.onerror = ev => reject(ev);
		});
	}

	/**
	 * Get N-Gram set from index.
	 *
	 * @param {string} column - name of column.
	 * @param {object} [options] - optional arguments.
	 * @param {boolean} [options.ignoreCase=false] - ignore case when make result.
	 *
	 * @return {Promise<Map<string, number>>}
	 */
	getNGrams(column, options={}) {
		if (!this.db.schema.ngramIndexes.has(column)) {
			return Promise.reject(new NoSuchColumnError(column));
		}

		const store = this.transaction.objectStore(this.db.index_prefix + 'ngram_' + column);
		const index = options.ignoreCase ? store.index('lower') : store.index('token');

		return this._readIndexSet(index);
	}

	/**
	 * Get word set from index.
	 *
	 * @param {string} column - name of column.
	 * @param {object} [options] - optional arguments.
	 * @param {boolean} [options.ignoreCase=false] - ignore case when make result.
	 *
	 * @return {Promise<Map<string, number>>}
	 */
	getWords(column, options={}) {
		if (!this.db.schema.wordIndexes.has(column)) {
			return Promise.reject(new NoSuchColumnError(column));
		}

		const store = this.transaction.objectStore(this.db.index_prefix + 'word_' + column);
		const index = options.ignoreCase ? store.index('lower') : store.index('word');

		return this._readIndexSet(index);
	}
}