From 7077fbe5f40872f9555645dbad2e6729ca55a5d4 Mon Sep 17 00:00:00 2001 From: Kiro Risk <565580+krisk@users.noreply.github.com> Date: Mon, 22 Jun 2020 20:41:23 -0700 Subject: [PATCH] feat: provide alternative array notation for nested paths Closes #432 --- dist/fuse.js | 186 +++++++++++------- docs/examples.md | 7 +- src/core/index.js | 14 +- src/core/queryParser.js | 22 ++- src/helpers/get.js | 24 +-- src/index.d.ts | 8 +- src/tools/FuseIndex.js | 15 +- src/tools/KeyStore.js | 84 +++++--- src/transform/transformMatches.js | 2 +- .../__snapshots__/logical-search.test.js.snap | 14 +- test/fuzzy-search.test.js | 89 +++++++++ test/logical-search.test.js | 43 ++++ 12 files changed, 356 insertions(+), 152 deletions(-) diff --git a/dist/fuse.js b/dist/fuse.js index eb14d0d63..71b81d909 100644 --- a/dist/fuse.js +++ b/dist/fuse.js @@ -269,53 +269,33 @@ _classCallCheck(this, KeyStore); - this._keys = {}; - this._keyNames = []; + this._keys = []; + this._keyMap = {}; var totalWeight = 0; keys.forEach(function (key) { - var keyName; - var weight = 1; + var obj = createKey(key); + totalWeight += obj.weight; - if (isString(key)) { - keyName = key; - } else { - if (!hasOwn.call(key, 'name')) { - throw new Error(MISSING_KEY_PROPERTY('name')); - } - - keyName = key.name; - - if (hasOwn.call(key, 'weight')) { - weight = key.weight; + _this._keys.push(obj); - if (weight <= 0) { - throw new Error(INVALID_KEY_WEIGHT_VALUE(keyName)); - } - } - } - - _this._keyNames.push(keyName); - - _this._keys[keyName] = { - weight: weight - }; - totalWeight += weight; + _this._keyMap[obj.id] = obj; + totalWeight += obj.weight; }); // Normalize weights so that their sum is equal to 1 - this._keyNames.forEach(function (key) { - _this._keys[key].weight /= totalWeight; + this._keys.forEach(function (key) { + key.weight /= totalWeight; }); } _createClass(KeyStore, [{ key: "get", - value: function get(key, name) { - return this._keys[key] && this._keys[key][name]; + value: function get(keyId) { + return this._keyMap[keyId]; } }, { key: "keys", value: function keys() { - return this._keyNames; + return this._keys; } }, { key: "toJSON", @@ -326,47 +306,82 @@ return KeyStore; }(); + function createKey(key) { + var path = null; + var id = null; + var src = null; + var weight = 1; + + if (isString(key) || isArray(key)) { + src = key; + path = createKeyPath(key); + id = createKeyId(key); + } else { + if (!hasOwn.call(key, 'name')) { + throw new Error(MISSING_KEY_PROPERTY('name')); + } + + var name = key.name; + src = name; + + if (hasOwn.call(key, 'weight')) { + weight = key.weight; + + if (weight <= 0) { + throw new Error(INVALID_KEY_WEIGHT_VALUE(name)); + } + } + + path = createKeyPath(name); + id = createKeyId(name); + } + + return { + path: path, + id: id, + weight: weight, + src: src + }; + } + function createKeyPath(key) { + return isArray(key) ? key : key.split('.'); + } + function createKeyId(key) { + return isArray(key) ? key.join('.') : key; + } function get(obj, path) { var list = []; var arr = false; - var deepGet = function deepGet(obj, path) { - if (!path) { + var deepGet = function deepGet(obj, path, index) { + if (!path[index]) { // If there's no path left, we've arrived at the object we care about. list.push(obj); } else { - var dotIndex = path.indexOf('.'); - var key = path; - var remaining = null; - - if (dotIndex !== -1) { - key = path.slice(0, dotIndex); - remaining = path.slice(dotIndex + 1); - } - + var key = path[index]; var value = obj[key]; if (!isDefined(value)) { return; } - if (!remaining && (isString(value) || isNumber(value))) { + if (index === path.length - 1 && (isString(value) || isNumber(value))) { list.push(toString(value)); } else if (isArray(value)) { arr = true; // Search each item in the array. for (var i = 0, len = value.length; i < len; i += 1) { - deepGet(value[i], remaining); + deepGet(value[i], path, index + 1); } - } else if (remaining) { + } else if (path.length) { // An object. Recurse further. - deepGet(value, remaining); + deepGet(value, path, index + 1); } } }; - deepGet(obj, path); + deepGet(obj, path, 0); return arr ? list : list[0]; } @@ -479,13 +494,19 @@ }, { key: "setKeys", value: function setKeys() { + var _this = this; + var keys = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; this.keys = keys; + this._keysMap = {}; + keys.forEach(function (key, idx) { + _this._keysMap[key.id] = idx; + }); } }, { key: "create", value: function create() { - var _this = this; + var _this2 = this; if (this.isCreated || !this.docs.length) { return; @@ -495,12 +516,12 @@ if (isString(this.docs[0])) { this.docs.forEach(function (doc, docIndex) { - _this._addString(doc, docIndex); + _this2._addString(doc, docIndex); }); } else { // List is Array this.docs.forEach(function (doc, docIndex) { - _this._addObject(doc, docIndex); + _this2._addObject(doc, docIndex); }); } @@ -528,6 +549,11 @@ this.records[i].i -= 1; } } + }, { + key: "getValueForItemAtKeyId", + value: function getValueForItemAtKeyId(item, keyId) { + return item[this._keysMap[keyId]]; + } }, { key: "size", value: function size() { @@ -550,7 +576,7 @@ }, { key: "_addObject", value: function _addObject(doc, docIndex) { - var _this2 = this; + var _this3 = this; var record = { i: docIndex, @@ -558,7 +584,8 @@ }; // Iterate over every key (i.e, path), and fetch the value at that key this.keys.forEach(function (key, keyIndex) { - var value = _this2.getFn(doc, key); + // console.log(key) + var value = _this3.getFn(doc, key.path); if (!isDefined(value)) { return; @@ -585,7 +612,7 @@ var subRecord = { v: _value, i: nestedArrIndex, - n: _this2.norm.get(_value) + n: _this3.norm.get(_value) }; subRecords.push(subRecord); } else if (isArray(_value)) { @@ -603,7 +630,7 @@ } else if (!isBlank(value)) { var subRecord = { v: value, - n: _this2.norm.get(value) + n: _this3.norm.get(value) }; record.$[keyIndex] = subRecord; } @@ -630,8 +657,9 @@ var myIndex = new FuseIndex({ getFn: getFn }); - var keyStore = new KeyStore(keys); - myIndex.setKeys(keyStore.keys()); + myIndex.setKeys(keys.map(function (key) { + return createKey(key); + })); myIndex.setSources(docs); myIndex.create(); return myIndex; @@ -672,7 +700,7 @@ }; if (match.key) { - obj.key = match.key; + obj.key = match.key.src; } if (match.idx > -1) { @@ -1664,11 +1692,19 @@ AND: '$and', OR: '$or' }; + var KeyType = { + PATH: '$path', + PATTERN: '$val' + }; var isExpression = function isExpression(query) { return !!(query[LogicalOperator.AND] || query[LogicalOperator.OR]); }; + var isPath = function isPath(query) { + return !!query[KeyType.PATH]; + }; + var isLeaf = function isLeaf(query) { return !isArray(query) && isObject(query) && !isExpression(query); }; @@ -1688,22 +1724,22 @@ var next = function next(query) { var keys = Object.keys(query); + var isQueryPath = isPath(query); - if (keys.length > 1 && !isExpression(query)) { + if (!isQueryPath && keys.length > 1 && !isExpression(query)) { return next(convertToExplicit(query)); } - var key = keys[0]; - if (isLeaf(query)) { - var pattern = query[key]; + var key = isQueryPath ? query[KeyType.PATH] : keys[0]; + var pattern = isQueryPath ? query[KeyType.PATTERN] : query[key]; if (!isString(pattern)) { throw new Error(LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY(key)); } var obj = { - key: key, + keyId: createKeyId(key), pattern: pattern }; @@ -1716,7 +1752,7 @@ var node = { children: [], - operator: key + operator: keys[0] }; keys.forEach(function (key) { var value = query[key]; @@ -1763,7 +1799,7 @@ throw new Error(INCORRECT_INDEX_TYPE); } - this._myIndex = index || createIndex(this._keyStore.keys(), this._docs, { + this._myIndex = index || createIndex(this.options.keys, this._docs, { getFn: this.options.getFn }); } @@ -1886,9 +1922,7 @@ var _this = this; var expression = parse(query, this.options); - var _this$_myIndex = this._myIndex, - keys = _this$_myIndex.keys, - records = _this$_myIndex.records; + var records = this._myIndex.records; var resultMap = {}; var results = []; @@ -1921,11 +1955,13 @@ return res; } else { - var key = node.key, + var keyId = node.keyId, searcher = node.searcher; - var value = item[keys.indexOf(key)]; + + var value = _this._myIndex.getValueForItemAtKeyId(item, keyId); + return _this._findMatches({ - key: key, + key: _this._keyStore.get(keyId), value: value, searcher: searcher }); @@ -1968,9 +2004,9 @@ var _this2 = this; var searcher = createSearcher(query, this.options); - var _this$_myIndex2 = this._myIndex, - keys = _this$_myIndex2.keys, - records = _this$_myIndex2.records; + var _this$_myIndex = this._myIndex, + keys = _this$_myIndex.keys, + records = _this$_myIndex.records; var results = []; // List is Array records.forEach(function (_ref5) { @@ -2076,7 +2112,7 @@ var key = _ref9.key, norm = _ref9.norm, score = _ref9.score; - var weight = keyStore.get(key, 'weight'); + var weight = key ? key.weight : null; totalScore *= Math.pow(score === 0 && weight ? Number.EPSILON : score, (weight || 1) * (ignoreFieldNorm ? 1 : norm)); }); result.score = totalScore; diff --git a/docs/examples.md b/docs/examples.md index 07299f83e..36ba422ca 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -95,12 +95,10 @@ const result = fuse.search('tion') ::: :::: - - ## Nested Search +You can search through nested values by providing the path via dot (`.`) or array notation. + :::: tabs ::: tab List @@ -137,6 +135,7 @@ const list = [ ```javascript const options = { includeScore: true, + // equivalent to `keys: [['author', 'tags', 'value']]` keys: ['author.tags.value'] } diff --git a/src/core/index.js b/src/core/index.js index 2a70a783c..1a05b2fa4 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -32,7 +32,7 @@ export default class Fuse { this._myIndex = index || - createIndex(this._keyStore.keys(), this._docs, { + createIndex(this.options.keys, this._docs, { getFn: this.options.getFn }) } @@ -133,7 +133,8 @@ export default class Fuse { } const expression = parse(query, this.options) - const { keys, records } = this._myIndex + + const records = this._myIndex.records const resultMap = {} const results = [] @@ -165,11 +166,12 @@ export default class Fuse { return res } else { - const { key, searcher } = node - const value = item[keys.indexOf(key)] + const { keyId, searcher } = node + + const value = this._myIndex.getValueForItemAtKeyId(item, keyId) return this._findMatches({ - key, + key: this._keyStore.get(keyId), value, searcher }) @@ -281,7 +283,7 @@ function computeScore( let totalScore = 1 result.matches.forEach(({ key, norm, score }) => { - const weight = keyStore.get(key, 'weight') + const weight = key ? key.weight : null totalScore *= Math.pow( score === 0 && weight ? Number.EPSILON : score, diff --git a/src/core/queryParser.js b/src/core/queryParser.js index cc3301f0d..1adc3192d 100644 --- a/src/core/queryParser.js +++ b/src/core/queryParser.js @@ -1,15 +1,23 @@ import { isArray, isObject, isString } from '../helpers/types' import { createSearcher } from './register' import * as ErrorMsg from './errorMessages' +import { createKeyId } from '../tools/KeyStore' export const LogicalOperator = { AND: '$and', OR: '$or' } +const KeyType = { + PATH: '$path', + PATTERN: '$val' +} + const isExpression = (query) => !!(query[LogicalOperator.AND] || query[LogicalOperator.OR]) +const isPath = (query) => !!query[KeyType.PATH] + const isLeaf = (query) => !isArray(query) && isObject(query) && !isExpression(query) @@ -25,21 +33,23 @@ export function parse(query, options, { auto = true } = {}) { const next = (query) => { let keys = Object.keys(query) - if (keys.length > 1 && !isExpression(query)) { + const isQueryPath = isPath(query) + + if (!isQueryPath && keys.length > 1 && !isExpression(query)) { return next(convertToExplicit(query)) } - let key = keys[0] - if (isLeaf(query)) { - const pattern = query[key] + const key = isQueryPath ? query[KeyType.PATH] : keys[0] + + const pattern = isQueryPath ? query[KeyType.PATTERN] : query[key] if (!isString(pattern)) { throw new Error(ErrorMsg.LOGICAL_SEARCH_INVALID_QUERY_FOR_KEY(key)) } const obj = { - key, + keyId: createKeyId(key), pattern } @@ -52,7 +62,7 @@ export function parse(query, options, { auto = true } = {}) { let node = { children: [], - operator: key + operator: keys[0] } keys.forEach((key) => { diff --git a/src/helpers/get.js b/src/helpers/get.js index 27a711cb9..ce0f92672 100644 --- a/src/helpers/get.js +++ b/src/helpers/get.js @@ -4,20 +4,12 @@ export default function get(obj, path) { let list = [] let arr = false - const deepGet = (obj, path) => { - if (!path) { + const deepGet = (obj, path, index) => { + if (!path[index]) { // If there's no path left, we've arrived at the object we care about. list.push(obj) } else { - const dotIndex = path.indexOf('.') - - let key = path - let remaining = null - - if (dotIndex !== -1) { - key = path.slice(0, dotIndex) - remaining = path.slice(dotIndex + 1) - } + let key = path[index] const value = obj[key] @@ -25,22 +17,22 @@ export default function get(obj, path) { return } - if (!remaining && (isString(value) || isNumber(value))) { + if (index === path.length - 1 && (isString(value) || isNumber(value))) { list.push(toString(value)) } else if (isArray(value)) { arr = true // Search each item in the array. for (let i = 0, len = value.length; i < len; i += 1) { - deepGet(value[i], remaining) + deepGet(value[i], path, index + 1) } - } else if (remaining) { + } else if (path.length) { // An object. Recurse further. - deepGet(value, remaining) + deepGet(value, path, index + 1) } } } - deepGet(obj, path) + deepGet(obj, path, 0) return arr ? list : list[0] } diff --git a/src/index.d.ts b/src/index.d.ts index 930c9d4e7..4a8d07748 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -82,7 +82,7 @@ declare class Fuse> { * @returns An indexed list */ static createIndex( - keys: Fuse.FuseOptionKeyObject[] | string[], + keys: Array, list: ReadonlyArray, options?: Fuse.FuseIndexOptions ): FuseIndex @@ -228,10 +228,12 @@ declare namespace Fuse { // weight: 0.7 // } export type FuseOptionKeyObject = { - name: string + name: string | [string] weight: number } + export type FuseOptionKey = FuseOptionKeyObject | string | [string] + export interface IFuseOptions { isCaseSensitive?: boolean distance?: number @@ -241,7 +243,7 @@ declare namespace Fuse { ignoreFieldNorm?: boolean includeMatches?: boolean includeScore?: boolean - keys?: FuseOptionKeyObject[] | string[] + keys?: Array location?: number minMatchCharLength?: number shouldSort?: boolean diff --git a/src/tools/FuseIndex.js b/src/tools/FuseIndex.js index 84d593538..206b4b330 100644 --- a/src/tools/FuseIndex.js +++ b/src/tools/FuseIndex.js @@ -1,7 +1,7 @@ import { isArray, isDefined, isString, isBlank } from '../helpers/types' import Config from '../core/config' import normGenerator from './norm' -import KeyStore from './KeyStore' +import KeyStore, { createKey } from './KeyStore' export default class FuseIndex { constructor({ getFn = Config.getFn } = {}) { @@ -19,6 +19,10 @@ export default class FuseIndex { } setKeys(keys = []) { this.keys = keys + this._keysMap = {} + keys.forEach((key, idx) => { + this._keysMap[key.id] = idx + }) } create() { if (this.isCreated || !this.docs.length) { @@ -60,6 +64,9 @@ export default class FuseIndex { this.records[i].i -= 1 } } + getValueForItemAtKeyId(item, keyId) { + return item[this._keysMap[keyId]] + } size() { return this.records.length } @@ -81,7 +88,8 @@ export default class FuseIndex { // Iterate over every key (i.e, path), and fetch the value at that key this.keys.forEach((key, keyIndex) => { - let value = this.getFn(doc, key) + // console.log(key) + let value = this.getFn(doc, key.path) if (!isDefined(value)) { return @@ -141,8 +149,7 @@ export default class FuseIndex { export function createIndex(keys, docs, { getFn = Config.getFn } = {}) { const myIndex = new FuseIndex({ getFn }) - const keyStore = new KeyStore(keys) - myIndex.setKeys(keyStore.keys()) + myIndex.setKeys(keys.map((key) => createKey(key))) myIndex.setSources(docs) myIndex.create() return myIndex diff --git a/src/tools/KeyStore.js b/src/tools/KeyStore.js index fce1f4a73..40ed42153 100644 --- a/src/tools/KeyStore.js +++ b/src/tools/KeyStore.js @@ -1,55 +1,79 @@ -import { isString } from '../helpers/types' +import { isString, isArray } from '../helpers/types' import * as ErrorMsg from '../core/errorMessages' const hasOwn = Object.prototype.hasOwnProperty export default class KeyStore { constructor(keys) { - this._keys = {} - this._keyNames = [] + this._keys = [] + this._keyMap = {} let totalWeight = 0 keys.forEach((key) => { - let keyName - let weight = 1 - - if (isString(key)) { - keyName = key - } else { - if (!hasOwn.call(key, 'name')) { - throw new Error(ErrorMsg.MISSING_KEY_PROPERTY('name')) - } - keyName = key.name - - if (hasOwn.call(key, 'weight')) { - weight = key.weight - - if (weight <= 0) { - throw new Error(ErrorMsg.INVALID_KEY_WEIGHT_VALUE(keyName)) - } - } - } + let obj = createKey(key) - this._keyNames.push(keyName) + totalWeight += obj.weight - this._keys[keyName] = { weight } + this._keys.push(obj) + this._keyMap[obj.id] = obj - totalWeight += weight + totalWeight += obj.weight }) // Normalize weights so that their sum is equal to 1 - this._keyNames.forEach((key) => { - this._keys[key].weight /= totalWeight + this._keys.forEach((key) => { + key.weight /= totalWeight }) } - get(key, name) { - return this._keys[key] && this._keys[key][name] + get(keyId) { + return this._keyMap[keyId] } keys() { - return this._keyNames + return this._keys } toJSON() { return JSON.stringify(this._keys) } } + +export function createKey(key) { + let path = null + let id = null + let src = null + let weight = 1 + + if (isString(key) || isArray(key)) { + src = key + path = createKeyPath(key) + id = createKeyId(key) + } else { + if (!hasOwn.call(key, 'name')) { + throw new Error(ErrorMsg.MISSING_KEY_PROPERTY('name')) + } + + const name = key.name + src = name + + if (hasOwn.call(key, 'weight')) { + weight = key.weight + + if (weight <= 0) { + throw new Error(ErrorMsg.INVALID_KEY_WEIGHT_VALUE(name)) + } + } + + path = createKeyPath(name) + id = createKeyId(name) + } + + return { path, id, weight, src } +} + +export function createKeyPath(key) { + return isArray(key) ? key : key.split('.') +} + +export function createKeyId(key) { + return isArray(key) ? key.join('.') : key +} diff --git a/src/transform/transformMatches.js b/src/transform/transformMatches.js index c227689a9..69ffbbac0 100644 --- a/src/transform/transformMatches.js +++ b/src/transform/transformMatches.js @@ -21,7 +21,7 @@ export default function transformMatches(result, data) { } if (match.key) { - obj.key = match.key + obj.key = match.key.src } if (match.idx > -1) { diff --git a/test/__snapshots__/logical-search.test.js.snap b/test/__snapshots__/logical-search.test.js.snap index a3dc3ae6a..79b337f57 100644 --- a/test/__snapshots__/logical-search.test.js.snap +++ b/test/__snapshots__/logical-search.test.js.snap @@ -4,7 +4,7 @@ exports[`Logical parser Implicit operations 1`] = ` Object { "children": Array [ Object { - "key": "title", + "keyId": "title", "pattern": "old war", }, Object { @@ -12,18 +12,18 @@ Object { Object { "children": Array [ Object { - "key": "title", + "keyId": "title", "pattern": "!arts", }, Object { - "key": "tags", + "keyId": "tags", "pattern": "kiro", }, ], "operator": "$and", }, Object { - "key": "title", + "keyId": "title", "pattern": "^lock", }, ], @@ -38,17 +38,17 @@ exports[`Logical parser Tree structure 1`] = ` Object { "children": Array [ Object { - "key": "title", + "keyId": "title", "pattern": "old war", }, Object { "children": Array [ Object { - "key": "title", + "keyId": "title", "pattern": "!arts", }, Object { - "key": "title", + "keyId": "title", "pattern": "^lock", }, ], diff --git a/test/fuzzy-search.test.js b/test/fuzzy-search.test.js index 5dda52d1a..e5182246b 100644 --- a/test/fuzzy-search.test.js +++ b/test/fuzzy-search.test.js @@ -1038,3 +1038,92 @@ describe('Ignore location and field length norm', () => { expect(result).toMatchSnapshot() }) }) + +describe('Standard dotted keys', () => { + test('We get mathes', () => { + const list = [ + { + title: 'HTML5', + author: { + firstName: 'Remy', + lastName: 'Sharp' + } + }, + { + title: 'Angels & Demons', + author: { + firstName: 'rmy', + lastName: 'Brown' + } + } + ] + + const fuse = new Fuse(list, { + keys: ['title', ['author', 'firstName']], + includeMatches: true, + includeScore: true + }) + + const result = fuse.search('remy') + + expect(result).toHaveLength(2) + }) + + test('We get a result with no matches', () => { + const list = [ + { + title: 'HTML5', + author: { + 'first.name': 'Remy', + 'last.name': 'Sharp' + } + }, + { + title: 'Angels & Demons', + author: { + 'first.name': 'rmy', + 'last.name': 'Brown' + } + } + ] + + const fuse = new Fuse(list, { + keys: ['title', ['author', 'first.name']], + includeMatches: true, + includeScore: true + }) + + const result = fuse.search('remy') + + expect(result).toHaveLength(2) + }) + + test('Keys with weights', () => { + const list = [ + { + title: 'HTML5', + author: { + firstName: 'Remy', + lastName: 'Sharp' + } + }, + { + title: 'Angels & Demons', + author: { + firstName: 'rmy', + lastName: 'Brown' + } + } + ] + + const fuse = new Fuse(list, { + keys: [{ name: 'title' }, { name: ['author', 'firstName'] }], + includeMatches: true, + includeScore: true + }) + + const result = fuse.search('remy') + + expect(result).toHaveLength(2) + }) +}) diff --git a/test/logical-search.test.js b/test/logical-search.test.js index 66f9ae7ec..73dd909b9 100644 --- a/test/logical-search.test.js +++ b/test/logical-search.test.js @@ -191,3 +191,46 @@ describe('Multiple nested conditions', () => { expect(result.length).toBe(1) }) }) + +describe('Logical search with dotted keys', () => { + const list = [ + { + title: "Old Man's War", + author: { + 'first.name': 'John', + 'last.name': 'Scalzi', + age: '61' + } + } + ] + + const options = { + useExtendedSearch: true, + keys: [ + 'title', + ['author', 'first.name'], + ['author', 'last.name'], + 'author.age' + ] + } + const fuse = new Fuse(list, options) + + test('Search: deep nested AND + OR', () => { + const query = { + $and: [ + { + $path: ['author', 'first.name'], + $val: 'jon' + }, + { + $path: ['author', 'last.name'], + $val: 'scazi' + } + ] + } + + const result = fuse.search(query) + + expect(result.length).toBe(1) + }) +})