mirror of https://github.com/grafana/grafana
Grafana/data: Extract fuzzy search core (#107110)
* Move fuzzy search to grafana/data * Move @leeoniya/ufuzzy package * Cleanup * Use exact version * mark export as internalpull/106335/merge
parent
24884154dc
commit
3d1b820827
@ -0,0 +1,103 @@ |
|||||||
|
import { fuzzySearch } from './fuzzySearch'; |
||||||
|
|
||||||
|
describe('fuzzySearch', () => { |
||||||
|
it('should return all indices when needle is empty', () => { |
||||||
|
const haystack = ['A', 'B', 'C', 'D']; |
||||||
|
const result = fuzzySearch(haystack, ''); |
||||||
|
expect(result).toEqual([0, 1, 2, 3]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should properly rank by match quality', () => { |
||||||
|
const haystack = ['A', 'AA', 'AB', 'AC', 'BC', 'C', 'CD']; |
||||||
|
const needle = 'C'; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
|
||||||
|
const matches = result.map((idx) => haystack[idx]); |
||||||
|
expect(matches).toEqual(['C', 'CD', 'AC', 'BC']); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should handle case sensitivity and order by match quality', () => { |
||||||
|
const haystack = [ |
||||||
|
'client_service_namespace', |
||||||
|
'namespace', |
||||||
|
'alert_namespace', |
||||||
|
'container_namespace', |
||||||
|
'Namespace', |
||||||
|
'client_k8s_namespace_name', |
||||||
|
'foobar', |
||||||
|
]; |
||||||
|
const needle = 'Names'; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
const matches = result.map((idx) => haystack[idx]); |
||||||
|
|
||||||
|
expect(matches).toEqual([ |
||||||
|
'Namespace', |
||||||
|
'namespace', |
||||||
|
'alert_namespace', |
||||||
|
'container_namespace', |
||||||
|
'client_k8s_namespace_name', |
||||||
|
'client_service_namespace', |
||||||
|
]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should do substring match when needle contains non-ascii characters', () => { |
||||||
|
const haystack = ['A水', 'AA', 'AB', 'AC', 'BC', 'C', 'CD']; |
||||||
|
const needle = '水'; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
|
||||||
|
expect(result.map((idx) => haystack[idx])).toEqual(['A水']); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should handle multiple non-latin characters', () => { |
||||||
|
const haystack = ['台灣省', '台中市', '台北市', '台南市', '南投縣', '高雄市', '台中第一高級中學']; |
||||||
|
const needle = '南'; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
|
||||||
|
expect(result.map((idx) => haystack[idx])).toEqual(['台南市', '南投縣']); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should do substring match when needle contains only symbols', () => { |
||||||
|
const haystack = ['=', '<=', '>', '!~']; |
||||||
|
const needle = '='; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
|
||||||
|
expect(result.map((idx) => haystack[idx])).toEqual(['=', '<=']); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should handle empty haystack', () => { |
||||||
|
const haystack: string[] = []; |
||||||
|
const needle = 'test'; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
|
||||||
|
expect(result).toEqual([]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should handle no matches', () => { |
||||||
|
const haystack = ['apple', 'banana', 'cherry']; |
||||||
|
const needle = 'xyz'; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
|
||||||
|
expect(result).toEqual([]); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should return indices in the correct order', () => { |
||||||
|
const haystack = ['zebra', 'apple', 'aardvark', 'application']; |
||||||
|
const needle = 'app'; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
|
||||||
|
result.forEach((index) => { |
||||||
|
expect(haystack[index].toLowerCase()).toContain('app'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should handle partial matches', () => { |
||||||
|
const haystack = ['Dashboard', 'Dashboards', 'dash-config', 'config-dash']; |
||||||
|
const needle = 'dash'; |
||||||
|
const result = fuzzySearch(haystack, needle); |
||||||
|
|
||||||
|
expect(result.length).toEqual(haystack.length); |
||||||
|
result.forEach((index) => { |
||||||
|
expect(haystack[index].toLowerCase()).toContain('dash'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,63 @@ |
|||||||
|
import uFuzzy from '@leeoniya/ufuzzy'; |
||||||
|
|
||||||
|
// https://catonmat.net/my-favorite-regex :)
|
||||||
|
const REGEXP_NON_ASCII = /[^ -~]/m; |
||||||
|
// https://www.asciitable.com/
|
||||||
|
// matches only these: `~!@#$%^&*()_+-=[]\{}|;':",./<>?
|
||||||
|
const REGEXP_ONLY_SYMBOLS = /^[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+$/m; |
||||||
|
// limit max terms in needle that qualify for re-ordering
|
||||||
|
const outOfOrderLimit = 5; |
||||||
|
// beyond 25 chars fall back to substring search
|
||||||
|
const maxNeedleLength = 25; |
||||||
|
// beyond 5 terms fall back to substring match
|
||||||
|
const maxFuzzyTerms = 5; |
||||||
|
// when number of matches <= 1e4, do ranking + sorting by quality
|
||||||
|
const rankThreshold = 1e4; |
||||||
|
|
||||||
|
// typo tolerance mode
|
||||||
|
const uf = new uFuzzy({ intraMode: 1 }); |
||||||
|
|
||||||
|
/** |
||||||
|
* Fuzzy search utility that returns matching indices for a given search term |
||||||
|
* Uses intelligent fallback strategies for different types of input |
||||||
|
* @internal |
||||||
|
*/ |
||||||
|
export function fuzzySearch(haystack: string[], needle: string): number[] { |
||||||
|
if (needle === '') { |
||||||
|
return haystack.map((_, index) => index); |
||||||
|
} |
||||||
|
// fallback to substring matches to avoid badness
|
||||||
|
else if ( |
||||||
|
// contains non-ascii
|
||||||
|
REGEXP_NON_ASCII.test(needle) || |
||||||
|
// is only ascii symbols (operators)
|
||||||
|
REGEXP_ONLY_SYMBOLS.test(needle) || |
||||||
|
// too long (often copy-paste from somewhere)
|
||||||
|
needle.length > maxNeedleLength || |
||||||
|
uf.split(needle).length > maxFuzzyTerms |
||||||
|
) { |
||||||
|
const indices: number[] = []; |
||||||
|
for (let i = 0; i < haystack.length; i++) { |
||||||
|
let item = haystack[i]; |
||||||
|
|
||||||
|
if (item.includes(needle)) { |
||||||
|
indices.push(i); |
||||||
|
} |
||||||
|
} |
||||||
|
return indices; |
||||||
|
} |
||||||
|
// fuzzy search
|
||||||
|
else { |
||||||
|
const [idxs, info, order] = uf.search(haystack, needle, outOfOrderLimit, rankThreshold); |
||||||
|
|
||||||
|
if (idxs?.length) { |
||||||
|
if (info && order) { |
||||||
|
return order.map((idx) => info.idx[idx]); |
||||||
|
} else { |
||||||
|
return idxs; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return []; |
||||||
|
} |
@ -1,74 +0,0 @@ |
|||||||
import { fuzzyFind } from './filter'; |
|
||||||
|
|
||||||
describe('combobox filter', () => { |
|
||||||
it('should properly rank by match quality', () => { |
|
||||||
const needle = 'C'; |
|
||||||
|
|
||||||
const stringOptions = ['A', 'AA', 'AB', 'AC', 'BC', 'C', 'CD']; |
|
||||||
const options = stringOptions.map((value) => ({ value })); |
|
||||||
|
|
||||||
const matches = fuzzyFind(options, stringOptions, needle); |
|
||||||
|
|
||||||
expect(matches.map((m) => m.value)).toEqual(['C', 'CD', 'AC', 'BC']); |
|
||||||
}); |
|
||||||
|
|
||||||
it('orders by match quality and case sensitivty', () => { |
|
||||||
const stringOptions = [ |
|
||||||
'client_service_namespace', |
|
||||||
'namespace', |
|
||||||
'alert_namespace', |
|
||||||
'container_namespace', |
|
||||||
'Namespace', |
|
||||||
'client_k8s_namespace_name', |
|
||||||
'foobar', |
|
||||||
]; |
|
||||||
const options = stringOptions.map((value) => ({ value })); |
|
||||||
|
|
||||||
const matches = fuzzyFind(options, stringOptions, 'Names'); |
|
||||||
|
|
||||||
expect(matches.map((m) => m.value)).toEqual([ |
|
||||||
'Namespace', |
|
||||||
'namespace', |
|
||||||
'alert_namespace', |
|
||||||
'container_namespace', |
|
||||||
'client_k8s_namespace_name', |
|
||||||
'client_service_namespace', |
|
||||||
]); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('non-ascii', () => { |
|
||||||
it('should do substring match when needle is non-latin', () => { |
|
||||||
const needle = '水'; |
|
||||||
|
|
||||||
const stringOptions = ['A水', 'AA', 'AB', 'AC', 'BC', 'C', 'CD']; |
|
||||||
const options = stringOptions.map((value) => ({ value })); |
|
||||||
|
|
||||||
const matches = fuzzyFind(options, stringOptions, needle); |
|
||||||
|
|
||||||
expect(matches.map((m) => m.value)).toEqual(['A水']); |
|
||||||
}); |
|
||||||
|
|
||||||
it('second case for non-latin characters', () => { |
|
||||||
const stringOptions = ['台灣省', '台中市', '台北市', '台南市', '南投縣', '高雄市', '台中第一高級中學']; |
|
||||||
|
|
||||||
const options = stringOptions.map((value) => ({ value })); |
|
||||||
|
|
||||||
const matches = fuzzyFind(options, stringOptions, '南'); |
|
||||||
|
|
||||||
expect(matches.map((m) => m.value)).toEqual(['台南市', '南投縣']); |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
describe('operators', () => { |
|
||||||
it('should do substring match when needle is only symbols', () => { |
|
||||||
const needle = '='; |
|
||||||
|
|
||||||
const stringOptions = ['=', '<=', '>', '!~']; |
|
||||||
const options = stringOptions.map((value) => ({ value })); |
|
||||||
|
|
||||||
const matches = fuzzyFind(options, stringOptions, needle); |
|
||||||
|
|
||||||
expect(matches.map((m) => m.value)).toEqual(['=', '<=']); |
|
||||||
}); |
|
||||||
}); |
|
||||||
}); |
|
Loading…
Reference in new issue