From af4e659c061ce48996f3f7206b39080e369904c0 Mon Sep 17 00:00:00 2001 From: Kirollos Risk Date: Sun, 8 Mar 2020 17:52:20 -0700 Subject: [PATCH] Fixed #341 --- .gitignore | 4 +- dist/fuse.d.ts | 1 + dist/fuse.js | 2 +- package.json | 4 +- release.sh | 92 +++++++++ src/bitap/bitap_search.js | 15 +- src/bitap/index.js | 31 +-- src/helpers/deep_value.js | 39 ---- src/helpers/is_array.js | 1 - src/index.js | 258 ++++++++++++------------- src/utils.js | 71 +++++++ test/fuse.test.js | 10 +- test/{typings.test.ts => typings.t.ts} | 0 yarn.lock | 5 + 14 files changed, 334 insertions(+), 199 deletions(-) create mode 100755 release.sh delete mode 100644 src/helpers/deep_value.js delete mode 100644 src/helpers/is_array.js create mode 100644 src/utils.js rename test/{typings.test.ts => typings.t.ts} (100%) diff --git a/.gitignore b/.gitignore index 38c51e71a..4055fa8f7 100644 --- a/.gitignore +++ b/.gitignore @@ -14,4 +14,6 @@ node_modules/ package-lock.json # Filter test runner -runner.js \ No newline at end of file +runner.js + +perf/ \ No newline at end of file diff --git a/dist/fuse.d.ts b/dist/fuse.d.ts index 9ecd6b898..7ceff1448 100644 --- a/dist/fuse.d.ts +++ b/dist/fuse.d.ts @@ -29,6 +29,7 @@ declare class Fuse> { ) setCollection(list: ReadonlyArray): ReadonlyArray; + list: ReadonlyArray } declare namespace Fuse { diff --git a/dist/fuse.js b/dist/fuse.js index 01b0be76a..fce43121f 100644 --- a/dist/fuse.js +++ b/dist/fuse.js @@ -6,4 +6,4 @@ * * http://www.apache.org/licenses/LICENSE-2.0 */ -!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("Fuse",[],t):"object"==typeof exports?exports.Fuse=t():e.Fuse=t()}(this,function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="",n(n.s=1)}([function(e,t){e.exports=function(e){return Array.isArray?Array.isArray(e):"[object Array]"===Object.prototype.toString.call(e)}},function(e,t,n){function r(e){return(r="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}function o(e,t){for(var n=0;n1&&void 0!==arguments[1]?arguments[1]:{limit:!1};this._log('---------\nSearch pattern: "'.concat(e,'"'));var n=this._prepareSearchers(e),r=n.tokenSearchers,o=n.fullSearcher,i=this._search(r,o),a=i.weights,s=i.results;return this._computeScore(a,s),this.options.shouldSort&&this._sort(s),t.limit&&"number"==typeof t.limit&&(s=s.slice(0,t.limit)),this._format(s)}},{key:"_prepareSearchers",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=[];if(this.options.tokenize)for(var n=e.split(this.options.tokenSeparator),r=0,o=n.length;r0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1?arguments[1]:void 0,n=this.list,r={},o=[];if("string"==typeof n[0]){for(var i=0,a=n.length;i1)throw new Error("Key weight has to be > 0 and <= 1");d=d.name}else s[d]={weight:1};this._analyze({key:d,value:this.options.getFn(l,d),record:l,index:c},{resultMap:r,results:o,tokenSearchers:e,fullSearcher:t})}return{weights:s,results:o}}},{key:"_analyze",value:function(e,t){var n=e.key,r=e.arrayIndex,o=void 0===r?-1:r,i=e.value,a=e.record,c=e.index,h=t.tokenSearchers,l=void 0===h?[]:h,u=t.fullSearcher,f=void 0===u?[]:u,d=t.resultMap,v=void 0===d?{}:d,p=t.results,g=void 0===p?[]:p;if(null!=i){var y=!1,m=-1,k=0;if("string"==typeof i){this._log("\nKey: ".concat(""===n?"-":n));var S=f.search(i);if(this._log('Full text: "'.concat(i,'", score: ').concat(S.score)),this.options.tokenize){for(var x=i.split(this.options.tokenSeparator),b=[],M=0;M-1&&(P=(P+m)/2),this._log("Score average:",P);var F=!this.options.tokenize||!this.options.matchAllTokens||k>=l.length;if(this._log("\nCheck Matches: ".concat(F)),(y||S.isMatch)&&F){var T=v[c];T?T.output.push({key:n,arrayIndex:o,value:i,score:P,matchedIndices:S.matchedIndices}):(v[c]={item:a,output:[{key:n,arrayIndex:o,value:i,score:P,matchedIndices:S.matchedIndices}]},g.push(v[c]))}}else if(s(i))for(var z=0,E=i.length;z-1&&(a.arrayIndex=i.arrayIndex),t.matches.push(a)}}}),this.options.includeScore&&o.push(function(e,t){t.score=e.score});for(var i=0,a=e.length;in)return o(e,this.pattern,r);var a=this.options,s=a.location,c=a.distance,h=a.threshold,l=a.findAllMatches,u=a.minMatchCharLength;return i(e,this.pattern,this.patternAlphabet,{location:s,distance:c,threshold:h,findAllMatches:l,minMatchCharLength:u})}}])&&r(t.prototype,n),s&&r(t,s),e}();e.exports=s},function(e,t){var n=/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g;e.exports=function(e,t){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:/ +/g,o=new RegExp(t.replace(n,"\\$&").replace(r,"|")),i=e.match(o),a=!!i,s=[];if(a)for(var c=0,h=i.length;c=P;z-=1){var E=z-1,K=n[e.charAt(E)];if(K&&(x[E]=1),T[z]=(T[z+1]<<1|1)&K,0!==I&&(T[z]|=(L[z+1]|L[z])<<1|1|L[z+1]),T[z]&C&&(w=r(t,{errors:I,currentLocation:E,expectedLocation:g,distance:h}))<=m){if(m=w,(k=E)<=g)break;P=Math.max(1,2*g-k)}}if(r(t,{errors:I+1,currentLocation:g,expectedLocation:g,distance:h})>m)break;L=T}return{isMatch:k>=0,score:0===w?.001:w,matchedIndices:o(x,p)}}},function(e,t){e.exports=function(e,t){var n=t.errors,r=void 0===n?0:n,o=t.currentLocation,i=void 0===o?0:o,a=t.expectedLocation,s=void 0===a?0:a,c=t.distance,h=void 0===c?100:c,l=r/e.length,u=Math.abs(s-i);return h?l+u/h:u?1:l}},function(e,t){e.exports=function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1,n=[],r=-1,o=-1,i=0,a=e.length;i=t&&n.push([r,o]),r=-1)}return e[i-1]&&i-r>=t&&n.push([r,i-1]),n}},function(e,t){e.exports=function(e){for(var t={},n=e.length,r=0;r1&&void 0!==arguments[1]?arguments[1]:{limit:!1};this._log('---------\nSearch pattern: "'.concat(e,'"'));var n=this._prepareSearchers(e),r=n.tokenSearchers,o=n.fullSearcher,i=this._search(r,o),a=i.weights,s=i.results;return this._computeScore(a,s),this.options.shouldSort&&this._sort(s),t.limit&&"number"==typeof t.limit&&(s=s.slice(0,t.limit)),this._format(s)}},{key:"_prepareSearchers",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"",t=[];if(this.options.tokenize)for(var n=e.split(this.options.tokenSeparator),r=0,o=n.length;r0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1?arguments[1]:void 0,n=this.list,r={},o=[];if("string"==typeof n[0]){for(var i=0,a=n.length;i1)throw new Error("Key weight has to be > 0 and <= 1");v=v.name}else s[v]=1;this._analyze({key:v,value:this.options.getFn(h,v),record:h,index:c},{resultMap:r,results:o,tokenSearchers:e,fullSearcher:t})}return{weights:s,results:o}}},{key:"_analyze",value:function(e,t){var n=this,r=e.key,o=e.arrayIndex,i=void 0===o?-1:o,a=e.value,s=e.record,l=e.index,h=t.tokenSearchers,u=void 0===h?[]:h,f=t.fullSearcher,v=t.resultMap,d=void 0===v?{}:v,p=t.results,g=void 0===p?[]:p;!function e(t,o,i,a){if(null!=o)if("string"==typeof o){var s=!1,l=-1,h=0;n._log("\nKey: ".concat(""===r?"--":r));var v=f.search(o);if(n._log('Full text: "'.concat(o,'", score: ').concat(v.score)),n.options.tokenize){for(var p=o.split(n.options.tokenSeparator),y=p.length,m=[],k=0,S=u.length;k-1&&(O=(O+l)/2),n._log("Score average:",O);var j=!n.options.tokenize||!n.options.matchAllTokens||h>=u.length;if(n._log("\nCheck Matches: ".concat(j)),(s||v.isMatch)&&j){var I={key:r,arrayIndex:t,value:o,score:O};n.options.includeMatches&&(I.matchedIndices=v.matchedIndices);var P=d[a];P?P.output.push(I):(d[a]={item:i,output:[I]},g.push(d[a]))}}else if(c(o))for(var F=0,T=o.length;F0?Number.EPSILON:l.score;s*=Math.pow(f,u)}o.score=s,this._log(o)}}},{key:"_sort",value:function(e){this._log("\n\nSorting...."),e.sort(this.options.sortFn)}},{key:"_format",value:function(e){var t=[];if(this.options.verbose){var n=[];this._log("\n\nOutput:\n\n",JSON.stringify(e,function(e,t){if("object"===r(t)&&null!==t){if(-1!==n.indexOf(t))return;n.push(t)}return t})),n=null}var o=[];this.options.includeMatches&&o.push(function(e,t){var n=e.output;t.matches=[];for(var r=0,o=n.length;r-1&&(a.arrayIndex=i.arrayIndex),t.matches.push(a)}}}),this.options.includeScore&&o.push(function(e,t){t.score=e.score});for(var i=0,a=e.length;ic)return o(e,this.pattern,l);var h=this.options,u=h.location,f=h.distance,v=h.threshold,d=h.findAllMatches,p=h.minMatchCharLength;return i(e,this.pattern,this.patternAlphabet,{location:u,distance:f,threshold:v,findAllMatches:d,minMatchCharLength:p,includeMatches:r})}}])&&r(t.prototype,n),s&&r(t,s),e}();e.exports=s},function(e,t){var n=/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g;e.exports=function(e,t){var r=arguments.length>2&&void 0!==arguments[2]?arguments[2]:/ +/g,o=new RegExp(t.replace(n,"\\$&").replace(r,"|")),i=e.match(o),a=!!i,s=[];if(a)for(var c=0,l=i.length;c=T;N-=1){var K=N-1,$=n[e.charAt(K)];if($&&(M[K]=1),E[N]=(E[N+1]<<1|1)&$,0!==I&&(E[N]|=(w[N+1]|w[N])<<1|1|w[N+1]),E[N]&j&&(C=r(t,{errors:I,currentLocation:K,expectedLocation:m,distance:l}))<=S){if(S=C,(b=K)<=m)break;T=Math.max(1,2*m-b)}}if(r(t,{errors:I+1,currentLocation:m,expectedLocation:m,distance:l})>S)break;w=E}var J={isMatch:b>=0,score:0===C?.001:C};return y&&(J.matchedIndices=o(M,p)),J}},function(e,t){e.exports=function(e,t){var n=t.errors,r=void 0===n?0:n,o=t.currentLocation,i=void 0===o?0:o,a=t.expectedLocation,s=void 0===a?0:a,c=t.distance,l=void 0===c?100:c,h=r/e.length,u=Math.abs(s-i);return l?h+u/l:u?1:h}},function(e,t){e.exports=function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:1,n=[],r=-1,o=-1,i=0,a=e.length;i=t&&n.push([r,o]),r=-1)}return e[i-1]&&i-r>=t&&n.push([r,i-1]),n}},function(e,t){e.exports=function(e){for(var t={},n=e.length,r=0;r { +module.exports = (text, pattern, patternAlphabet, { location = 0, distance = 100, threshold = 0.6, findAllMatches = false, minMatchCharLength = 1, includeMatches = false }) => { const expectedLocation = location // Set starting location at beginning text and initialize the alphabet. const textLen = text.length @@ -135,8 +135,6 @@ module.exports = (text, pattern, patternAlphabet, { location = 0, distance = 100 distance }) - //console.log('score', score, finalScore) - if (score > currentThreshold) { break } @@ -144,12 +142,15 @@ module.exports = (text, pattern, patternAlphabet, { location = 0, distance = 100 lastBitArr = bitArr } - //console.log('FINAL SCORE', finalScore) - // Count exact matches (those with a score of 0) to be "almost" exact - return { + let _res = { isMatch: bestLocation >= 0, score: finalScore === 0 ? 0.001 : finalScore, - matchedIndices: matchedIndices(matchMask, minMatchCharLength) } + + if (includeMatches) { + _res.matchedIndices = matchedIndices(matchMask, minMatchCharLength) + } + + return _res } diff --git a/src/bitap/index.js b/src/bitap/index.js index 8c694a5a6..d40d88da6 100644 --- a/src/bitap/index.js +++ b/src/bitap/index.js @@ -25,7 +25,9 @@ class Bitap { // match is found before the end of the same input. findAllMatches = false, // Minimum number of characters that must be matched before a result is considered a match - minMatchCharLength = 1 + minMatchCharLength = 1, + + includeMatches = false }) { this.options = { location, @@ -35,10 +37,11 @@ class Bitap { isCaseSensitive, tokenSeparator, findAllMatches, + includeMatches, minMatchCharLength } - this.pattern = this.options.isCaseSensitive ? pattern : pattern.toLowerCase() + this.pattern = isCaseSensitive ? pattern : pattern.toLowerCase() if (this.pattern.length <= maxPatternLength) { this.patternAlphabet = patternAlphabet(this.pattern) @@ -46,20 +49,27 @@ class Bitap { } search (text) { - if (!this.options.isCaseSensitive) { + const { isCaseSensitive, includeMatches } = this.options + + if (!isCaseSensitive) { text = text.toLowerCase() } // Exact match if (this.pattern === text) { - return { + let result = { isMatch: true, - score: 0, - matchedIndices: [[0, text.length - 1]] + score: 0 + } + + if (includeMatches) { + result.matchedIndices = [[0, text.length - 1]] } + + return result } - // When pattern length is greater than the machine word length, just do a a regex comparison + // When pattern length is greater than the machine word length, just do a regex comparison const { maxPatternLength, tokenSeparator } = this.options if (this.pattern.length > maxPatternLength) { return bitapRegexSearch(text, this.pattern, tokenSeparator) @@ -72,13 +82,10 @@ class Bitap { distance, threshold, findAllMatches, - minMatchCharLength + minMatchCharLength, + includeMatches }) } } -// let x = new Bitap("od mn war", {}) -// let result = x.search("Old Man's War") -// console.log(result) - module.exports = Bitap diff --git a/src/helpers/deep_value.js b/src/helpers/deep_value.js deleted file mode 100644 index 4e892f273..000000000 --- a/src/helpers/deep_value.js +++ /dev/null @@ -1,39 +0,0 @@ -const isArray = require('./is_array') - -const deepValue = (obj, path, list) => { - if (!path) { - // If there's no path left, we've gotten to the object we care about. - list.push(obj) - } else { - const dotIndex = path.indexOf('.') - let firstSegment = path - let remaining = null - - if (dotIndex !== -1) { - firstSegment = path.slice(0, dotIndex) - remaining = path.slice(dotIndex + 1) - } - - const value = obj[firstSegment] - - if (value !== null && value !== undefined) { - if (!remaining && (typeof value === 'string' || typeof value === 'number')) { - list.push(value.toString()) - } else if (isArray(value)) { - // Search each item in the array. - for (let i = 0, len = value.length; i < len; i += 1) { - deepValue(value[i], remaining, list) - } - } else if (remaining) { - // An object. Recurse further. - deepValue(value, remaining, list) - } - } - } - - return list -} - -module.exports = (obj, path) => { - return deepValue(obj, path, []) -} diff --git a/src/helpers/is_array.js b/src/helpers/is_array.js deleted file mode 100644 index 4da920600..000000000 --- a/src/helpers/is_array.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = obj => !Array.isArray ? Object.prototype.toString.call(obj) === '[object Array]' : Array.isArray(obj) diff --git a/src/index.js b/src/index.js index ef5b86fb9..6128c471a 100644 --- a/src/index.js +++ b/src/index.js @@ -1,9 +1,8 @@ const Bitap = require('./bitap') -const deepValue = require('./helpers/deep_value') -const isArray = require('./helpers/is_array') +const { get, deepValue, isArray } = require('./utils') class Fuse { - constructor (list, { + constructor(list, { // Approximately where in the text is the pattern expected to be found? location = 0, // Determines how close the match must be to the fuzzy location (specified above). @@ -35,7 +34,7 @@ class Fuse { shouldSort = true, // The get function to use when fetching an object's properties. // The default will search nested paths *ie foo.bar.baz* - getFn = deepValue, + getFn = get, // Default sort function sortFn = (a, b) => (a.score - b.score), // When true, the search algorithm will search individual words **and** the full string, @@ -76,12 +75,12 @@ class Fuse { this.setCollection(list) } - setCollection (list) { + setCollection(list) { this.list = list return list } - search (pattern, opts = { limit: false }) { + search(pattern, opts = { limit: false }) { this._log(`---------\nSearch pattern: "${pattern}"`) const { @@ -89,9 +88,13 @@ class Fuse { fullSearcher } = this._prepareSearchers(pattern) + //console.time('Search'); let { weights, results } = this._search(tokenSearchers, fullSearcher) + //console.timeEnd('Search'); + //console.time('_computeScore'); this._computeScore(weights, results) + //console.timeEnd('_computeScore'); if (this.options.shouldSort) { this._sort(results) @@ -104,7 +107,7 @@ class Fuse { return this._format(results) } - _prepareSearchers (pattern = '') { + _prepareSearchers(pattern = '') { const tokenSearchers = [] if (this.options.tokenize) { @@ -115,12 +118,14 @@ class Fuse { } } + //console.time('_prepareSearchers') let fullSearcher = new Bitap(pattern, this.options) + //console.timeEnd('_prepareSearchers') return { tokenSearchers, fullSearcher } } - _search (tokenSearchers = [], fullSearcher) { + _search(tokenSearchers = [], fullSearcher) { const list = this.list const resultMap = {} const results = [] @@ -142,12 +147,12 @@ class Fuse { fullSearcher }) } - return { weights: null, results } } // Otherwise, the first item is an Object (hopefully), and thus the searching // is done on the values of the keys of each item. + //console.time('_search'); const weights = {} for (let i = 0, len = list.length; i < len; i += 1) { let item = list[i] @@ -155,17 +160,13 @@ class Fuse { for (let j = 0, keysLen = this.options.keys.length; j < keysLen; j += 1) { let key = this.options.keys[j] if (typeof key !== 'string') { - weights[key.name] = { - weight: (1 - key.weight) || 1 - } + weights[key.name] = key.weight//(1 - key.weight) || 1 if (key.weight <= 0 || key.weight > 1) { throw new Error('Key weight has to be > 0 and <= 1') } key = key.name } else { - weights[key] = { - weight: 1 - } + weights[key] = 1 } this._analyze({ @@ -182,165 +183,158 @@ class Fuse { } } + //console.timeEnd('_search'); + return { weights, results } } - _analyze ({ key, arrayIndex = -1, value, record, index }, { tokenSearchers = [], fullSearcher = [], resultMap = {}, results = [] }) { - // Check if the texvaluet can be searched - if (value === undefined || value === null) { - return - } + _analyze({ key, arrayIndex = -1, value, record, index }, { tokenSearchers = [], fullSearcher, resultMap = {}, results = [] }) { + // Internal search function for cleanless and speed up. + const search = (arrayIndex, value, record, index) => { + // Check if the texvaluet can be searched + if (value === undefined || value === null) { + return + } - let exists = false - let averageScore = -1 - let numTextMatches = 0 - - if (typeof value === 'string') { - this._log(`\nKey: ${key === '' ? '-' : key}`) - - let mainSearchResult = fullSearcher.search(value) - this._log(`Full text: "${value}", score: ${mainSearchResult.score}`) - - if (this.options.tokenize) { - let words = value.split(this.options.tokenSeparator) - let scores = [] - - for (let i = 0; i < tokenSearchers.length; i += 1) { - let tokenSearcher = tokenSearchers[i] - - this._log(`\nPattern: "${tokenSearcher.pattern}"`) - - // let tokenScores = [] - let hasMatchInText = false - - for (let j = 0; j < words.length; j += 1) { - let word = words[j] - let tokenSearchResult = tokenSearcher.search(word) - let obj = {} - if (tokenSearchResult.isMatch) { - obj[word] = tokenSearchResult.score - exists = true - hasMatchInText = true - scores.push(tokenSearchResult.score) - } else { - obj[word] = 1 - if (!this.options.matchAllTokens) { - scores.push(1) + if (typeof value === 'string') { + let exists = false + let averageScore = -1 + let numTextMatches = 0 + + this._log(`\nKey: ${key === '' ? '--' : key}`) + + // //console.time('_fullSearcher.search'); + let mainSearchResult = fullSearcher.search(value) + // //console.timeEnd('_fullSearcher.search'); + + this._log(`Full text: "${value}", score: ${mainSearchResult.score}`) + + // TODO: revisit this to take into account term frequency + if (this.options.tokenize) { + let words = value.split(this.options.tokenSeparator) + let wordsLen = words.length + let scores = [] + + for (let i = 0, tokenSearchersLen = tokenSearchers.length; i < tokenSearchersLen; i += 1) { + let tokenSearcher = tokenSearchers[i] + + this._log(`\nPattern: "${tokenSearcher.pattern}"`) + + let hasMatchInText = false + + for (let j = 0; j < wordsLen; j += 1) { + let word = words[j] + let tokenSearchResult = tokenSearcher.search(word) + let obj = {} + if (tokenSearchResult.isMatch) { + obj[word] = tokenSearchResult.score + exists = true + hasMatchInText = true + scores.push(tokenSearchResult.score) + } else { + obj[word] = 1 + if (!this.options.matchAllTokens) { + scores.push(1) + } } + this._log(`Token: "${word}", score: ${obj[word]}`) + } + + if (hasMatchInText) { + numTextMatches += 1 } - this._log(`Token: "${word}", score: ${obj[word]}`) - // tokenScores.push(obj) } - if (hasMatchInText) { - numTextMatches += 1 + averageScore = scores[0] + let scoresLen = scores.length + for (let i = 1; i < scoresLen; i += 1) { + averageScore += scores[i] } - } + averageScore = averageScore / scoresLen - averageScore = scores[0] - let scoresLen = scores.length - for (let i = 1; i < scoresLen; i += 1) { - averageScore += scores[i] + this._log('Token score average:', averageScore) } - averageScore = averageScore / scoresLen - this._log('Token score average:', averageScore) - } - - let finalScore = mainSearchResult.score - if (averageScore > -1) { - finalScore = (finalScore + averageScore) / 2 - } + let finalScore = mainSearchResult.score + if (averageScore > -1) { + finalScore = (finalScore + averageScore) / 2 + } - this._log('Score average:', finalScore) + this._log('Score average:', finalScore) - let checkTextMatches = (this.options.tokenize && this.options.matchAllTokens) ? numTextMatches >= tokenSearchers.length : true + let checkTextMatches = (this.options.tokenize && this.options.matchAllTokens) ? numTextMatches >= tokenSearchers.length : true - this._log(`\nCheck Matches: ${checkTextMatches}`) + this._log(`\nCheck Matches: ${checkTextMatches}`) - // If a match is found, add the item to , including its score - if ((exists || mainSearchResult.isMatch) && checkTextMatches) { - // Check if the item already exists in our results - let existingResult = resultMap[index] - if (existingResult) { - // Use the lowest score - // existingResult.score, bitapResult.score - existingResult.output.push({ + // If a match is found, add the item to , including its score + if ((exists || mainSearchResult.isMatch) && checkTextMatches) { + let _searchResult = { key, arrayIndex, value, - score: finalScore, - matchedIndices: mainSearchResult.matchedIndices - }) - } else { - // Add it to the raw result list - resultMap[index] = { - item: record, - output: [{ - key, - arrayIndex, - value, - score: finalScore, - matchedIndices: mainSearchResult.matchedIndices - }] + score: finalScore + } + + if (this.options.includeMatches) { + _searchResult.matchedIndices = mainSearchResult.matchedIndices } - results.push(resultMap[index]) + // Check if the item already exists in our results + let existingResult = resultMap[index] + if (existingResult) { + existingResult.output.push(_searchResult) + } else { + resultMap[index] = { + item: record, + output: [_searchResult] + } + + results.push(resultMap[index]) + } + } + } else if (isArray(value)) { + for (let i = 0, len = value.length; i < len; i += 1) { + search(i, value[i], record, index) } - } - } else if (isArray(value)) { - for (let i = 0, len = value.length; i < len; i += 1) { - this._analyze({ - key, - arrayIndex: i, - value: value[i], - record, - index - }, { - resultMap, - results, - tokenSearchers, - fullSearcher - }) } } + + search(arrayIndex, value, record, index) } - _computeScore (weights, results) { + _computeScore(weights, results) { this._log('\n\nComputing score:\n') for (let i = 0, len = results.length; i < len; i += 1) { - const output = results[i].output + const result = results[i] + const output = result.output const scoreLen = output.length - let currScore = 1 - let bestScore = 1 + let totalWeightedScore = 1 for (let j = 0; j < scoreLen; j += 1) { - let weight = weights ? weights[output[j].key].weight : 1 - let score = weight === 1 ? output[j].score : (output[j].score || 0.001) - let nScore = score * weight - - if (weight !== 1) { - bestScore = Math.min(bestScore, nScore) - } else { - output[j].nScore = nScore - currScore *= nScore - } + const item = output[j] + const key = item.key + const weight = weights ? weights[key] : 1 + const score = item.score === 0 && weights && weights[key] > 0 + ? Number.EPSILON + : item.score + + totalWeightedScore *= Math.pow(score, weight) } - results[i].score = bestScore === 1 ? currScore : bestScore + result.score = totalWeightedScore - this._log(results[i]) + this._log(result) } } - _sort (results) { + _sort(results) { this._log('\n\nSorting....') results.sort(this.options.sortFn) } - _format (results) { + _format(results) { const finalOutput = [] if (this.options.verbose) { @@ -420,7 +414,7 @@ class Fuse { return finalOutput } - _log () { + _log() { if (this.options.verbose) { console.log(...arguments) } diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 000000000..11ed23c4b --- /dev/null +++ b/src/utils.js @@ -0,0 +1,71 @@ +const INFINITY = 1 / 0 + +const isArray = value => !Array.isArray + ? Object.prototype.toString.call(value) === '[object Array]' + : Array.isArray(value) + +// Adapted from: +// https://github.com/lodash/lodash/blob/f4ca396a796435422bd4fd41fadbd225edddf175/.internal/baseToString.js +const baseToString = value => { + // Exit early for strings to avoid a performance hit in some environments. + if (typeof value == 'string') { + return value; + } + let result = (value + ''); + return (result == '0' && (1 / value) == -INFINITY) ? '-0' : result; +} + +const toString = value => value == null ? '' : baseToString(value); + +const isString = value => typeof value === 'string' + +const isNum = value => typeof value === 'number' + +const get = (obj, path) => { + let list = [] + + const _get = (obj, path) => { + if (!path) { + // If there's no path left, we've gotten to 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) + } + + const value = obj[key] + + if (value !== null && value !== undefined) { + if (!remaining && (isString(value) || isNum(value))) { + list.push(toString(value)) + } else if (isArray(value)) { + // Search each item in the array. + for (let i = 0, len = value.length; i < len; i += 1) { + _get(value[i], remaining) + } + } else if (remaining) { + // An object. Recurse further. + _get(value, remaining) + } + } + } + } + + _get(obj, path) + + return list +} + +module.exports = { + get, + isArray, + isString, + isNum, + toString +} diff --git a/test/fuse.test.js b/test/fuse.test.js index 6cb014c20..57d0dd552 100644 --- a/test/fuse.test.js +++ b/test/fuse.test.js @@ -1,6 +1,6 @@ const Fuse = require('../dist/fuse') const books = require('./fixtures/books.json') -const deepValue = require('../src/helpers/deep_value') +const { get } = require('../src/utils') const verbose = false @@ -17,7 +17,7 @@ const defaultOptions = { id: null, keys: [], shouldSort: true, - getFn: deepValue, + getFn: get, sortFn: (a, b) => (a.score - b.score), tokenize: false, matchAllTokens: false, @@ -763,9 +763,9 @@ describe('Weighted search', () => { describe('When searching for the term "War", where tags are weighted higher than all other keys', () => { const customOptions = { keys: [ - {name: 'title', weight: 0.8}, - {name: 'author', weight: 0.3}, - {name: 'tags', weight: 0.9} + {name: 'title', weight: 0.4}, + {name: 'author', weight: 0.1}, + {name: 'tags', weight: 0.5} ] } let fuse diff --git a/test/typings.test.ts b/test/typings.t.ts similarity index 100% rename from test/typings.test.ts rename to test/typings.t.ts diff --git a/yarn.lock b/yarn.lock index a7c6a87c7..dd5bb804b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2239,6 +2239,11 @@ extsprintf@^1.2.0: resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8= +faker@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/faker/-/faker-4.1.0.tgz#1e45bbbecc6774b3c195fad2835109c6d748cc3f" + integrity sha1-HkW7vsxndLPBlfrSg1EJxtdIzD8= + fast-deep-equal@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49"