Home Identifier Source Repository

src/lib/query-treo-database.js

// import queryTreoDatabase from 'path/to/queryTreoDatabase'
// let db = treo('databaseName', schema)
//   .use(queryTreoDatabase)

import contains from 'lodash/collection/contains'
import filter from 'lodash/collection/filter'
import first from 'lodash/array/first'
import isString from 'lodash/lang/isString'
import {default as extractKeys} from 'lodash/object/keys'
import last from 'lodash/array/last'
import findIndex from 'lodash/array/findIndex'
import map from 'lodash/collection/map'
import reject from 'lodash/collection/reject'
import size from 'lodash/collection/size'
import uniq from 'lodash/array/uniq'
import flatten from 'lodash/array/flatten'
import startsWith from 'lodash/string/startsWith'

import idbRange from 'idb-range'
import {cmp as idbComparison} from 'treo'
import checkAgainstQuery from '../helpers/check-course-against-query'

function canAdd({query, value, primaryKey, results}={}) {
	// Check if we want to add the current value to the results array.
	// Essentially, make sure that the current value passes the query,
	// and then that it's not already in the array.
	// Note that because JS checks against identity, we use isEqual to
	// do an equality check against the two objects.
	return checkAgainstQuery(query, value) && !contains(results, primaryKey)
}

function queryStore(query) {
	return new Promise((resolvePromise, rejectPromise) => {
		// Take a query object.
		// Grab a key out of it to operate on an index.
		// Set up a range from the low and high value from the values for that key.
		// Iterate over that range and index, checking each value against the query
		//     and making sure not to add duplicates.
		// Return the results.

		let results = []
		// Prevent invalid logic from not having a query.
		if (!size(query)) {
			return results
		}

		// Grab a key from the query to use as an index.
		// TODO: Write a function to sort keys by priority.
		const indexKeys = extractKeys(query)

		// Filter down to just the requested keys that also have indices
		const keysWithIndices = filter(indexKeys, key => this.index(key))
		// <this is a very hacky way of prioritizing the deptnum>
		if (contains(keysWithIndices, 'deptnum')) {
			keysWithIndices.splice(findIndex('deptnum'), 1)
			keysWithIndices.unshift('deptnum')
		}

		// If the current store has at least one index for a requested key,
		// just run over that index.
		if (size(keysWithIndices)) {
			// We only want to search some indices
			const indices = filter(this.indexes, index => contains(keysWithIndices, index.name))

			// Run the queries
			const resultPromises = map(indices,
				index => index.query(query, true))

			// Wait for all indices to finish querying before getting their results
			const allFoundKeys = Promise.all(resultPromises)

			// Once we have the primary keys, we need to fetch the actual data:
			let allValues = allFoundKeys
				// they're in sub-arrays, one for each index, so we flatten them
				.then(keys => flatten(keys))
				// because multiple indices can be running at once, they might return
				// the same primary keys, so we'll just de-dupe them here before fetching
				.then(keys => uniq(keys))
				// and then we actually go fetch them
				.then(keys => this.batchGet(keys))

			// Once they've been fetched, resolve the promise with the results.
			allValues.then(resolvePromise)
		}

		// Otherwise, if the current store doesn't have an index for any of
		// the requested keys, iterate over the entire store.
		else {
			const done = err => {
				if (err) rejectPromise(err)
				this.batchGet(results).then(resolvePromise)
			}

			let iterateStore = cursor => {
				let {value, primaryKey} = cursor
				if (canAdd({query, value, primaryKey, results})) {
					results.push(primaryKey)
				}
				cursor.continue()
			}

			this.cursor({iterator: iterateStore}, done)
		}
	})
}


function queryIndex(query, primaryKeysOnly=false) {
	let name = this.name

	return new Promise((resolvePromise, rejectPromise) => {
		// - takes a query object
		// - filters down the props to just the current index's name
		// - if there aren't any keys to look for under the current index, return []
		// - otherwise, execute the query

		let results = []

		// Prevent invalid logic from not having a query.
		if (!query || !size(query) || !size(query[name])) {
			resolvePromise(results)
		}

		// The index of our current key
		let currentIndex = 0

		// The keys to look for; the list of permissible values for that range from the query
		let keys = query[name]
		keys = reject(keys, key => startsWith(key, '$'))

		if (!keys.length) {
			resolvePromise(results)
		}

		// If we have any keys, sort them according to the IDB spec
		keys = keys.sort(idbComparison)
		keys = uniq(keys, true)

		let firstKey = first(keys)
		let lastKey = last(keys)

		// A range to limit ourselves to
		let range = idbRange({
			gte: firstKey,
			// If it's a string, append `uffff` because that's the highest
			// value in Unicode, which lets us make sure and iterate over all
			// values that we need.
			// hacks.mozilla.org/2014/06/breaking-the-borders-of-indexeddb
			lte: isString(lastKey) ? lastKey + 'uffff' : lastKey,
		})

		let done = err => {
			if (err) {
				rejectPromise(err)
			}
			else if (primaryKeysOnly) {
				resolvePromise(results)
			}
			else {
				this.objectStore
					.batchGet(results)
					.then(resolvePromise)
			}
		}

		function iterateIndex(cursor) {
			// console.log('cursor', cursor, arguments)
			// console.log('key', keys[currentIndex], 'idx', currentIndex)
			// console.log(keys)

			if (currentIndex > keys.length) {
				// console.log('done')
				// If we're out of keys, quit.
				done()
			}

			else if (cursor.key > keys[currentIndex]) {
				// console.log('greater')
				// If the cursor's key is "past" the current one, we need to skip
				// ahead to the next one key in the list of keys.
				let {value, primaryKey} = cursor
				if (canAdd({query, value, primaryKey, results})) {
					// console.log('adding', value)
					results.push(primaryKey)
				}
				currentIndex += 1

				// If we attempt to continue to a key that is before or equal
				// to the current cursor.key, IDB throws an error.
				// Therefore, if the current key equals the current key, we
				// just go forward by one.
				let nextKey = (keys[currentIndex] <= cursor.key) ? undefined : keys[currentIndex]
				cursor.continue(nextKey)
			}

			else if (cursor.key === keys[currentIndex]) {
				// console.log('equals')
				// If we've found what we're looking for, add it, and go to
				// the next result.
				let {value, primaryKey} = cursor
				if (canAdd({query, value, primaryKey, results})) {
					// console.log('adding', value)
					results.push(primaryKey)
				}
				cursor.continue()
			}

			else {
				// console.log('other')
				// Otherwise, we're not there yet, and need to skip ahead to the
				// first occurrence of our current key.
				cursor.continue(keys[currentIndex])
			}
		}

		this.cursor({range, iterator: iterateIndex}, done)
	})
}

function plugin(db, treo) {
	let {Store, Index} = treo

	Store.prototype.query = queryStore
	Index.prototype.query = queryIndex
}

export default plugin