mirror of https://github.com/grafana/grafana.git
				
				
				
			Grafana/data: Extract fuzzy search core (#107110)
* Move fuzzy search to grafana/data * Move @leeoniya/ufuzzy package * Cleanup * Use exact version * mark export as internal
This commit is contained in:
		
							parent
							
								
									24884154dc
								
							
						
					
					
						commit
						3d1b820827
					
				|  | @ -57,6 +57,7 @@ | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@braintree/sanitize-url": "7.0.1", |     "@braintree/sanitize-url": "7.0.1", | ||||||
|     "@grafana/schema": "12.1.0-pre", |     "@grafana/schema": "12.1.0-pre", | ||||||
|  |     "@leeoniya/ufuzzy": "1.0.18", | ||||||
|     "@types/d3-interpolate": "^3.0.0", |     "@types/d3-interpolate": "^3.0.0", | ||||||
|     "@types/string-hash": "1.1.3", |     "@types/string-hash": "1.1.3", | ||||||
|     "@types/systemjs": "6.15.1", |     "@types/systemjs": "6.15.1", | ||||||
|  |  | ||||||
|  | @ -258,8 +258,9 @@ export * as arrayUtils from './utils/arrayUtils'; | ||||||
| export { store, Store } from './utils/store'; | export { store, Store } from './utils/store'; | ||||||
| export { LocalStorageValueProvider } from './utils/LocalStorageValueProvider'; | export { LocalStorageValueProvider } from './utils/LocalStorageValueProvider'; | ||||||
| export { throwIfAngular } from './utils/throwIfAngular'; | export { throwIfAngular } from './utils/throwIfAngular'; | ||||||
|  | export { fuzzySearch } from './utils/fuzzySearch'; | ||||||
| 
 | 
 | ||||||
| // Tranformations
 | // Transformations
 | ||||||
| export { standardTransformers } from './transformations/transformers'; | export { standardTransformers } from './transformations/transformers'; | ||||||
| export { | export { | ||||||
|   fieldMatchers, |   fieldMatchers, | ||||||
|  |  | ||||||
|  | @ -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 []; | ||||||
|  | } | ||||||
|  | @ -72,7 +72,6 @@ | ||||||
|     "@grafana/i18n": "12.1.0-pre", |     "@grafana/i18n": "12.1.0-pre", | ||||||
|     "@grafana/schema": "12.1.0-pre", |     "@grafana/schema": "12.1.0-pre", | ||||||
|     "@hello-pangea/dnd": "18.0.1", |     "@hello-pangea/dnd": "18.0.1", | ||||||
|     "@leeoniya/ufuzzy": "1.0.18", |  | ||||||
|     "@monaco-editor/react": "4.7.0", |     "@monaco-editor/react": "4.7.0", | ||||||
|     "@popperjs/core": "2.11.8", |     "@popperjs/core": "2.11.8", | ||||||
|     "@react-aria/dialog": "3.5.27", |     "@react-aria/dialog": "3.5.27", | ||||||
|  |  | ||||||
|  | @ -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(['=', '<=']); |  | ||||||
|     }); |  | ||||||
|   }); |  | ||||||
| }); |  | ||||||
|  | @ -1,23 +1,6 @@ | ||||||
| import uFuzzy from '@leeoniya/ufuzzy'; | import { fuzzySearch } from '@grafana/data'; | ||||||
| 
 | 
 | ||||||
| import { ALL_OPTION_VALUE, ComboboxOption } from './types'; | import { ComboboxOption } from './types'; | ||||||
| 
 |  | ||||||
| // 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 }); |  | ||||||
| 
 | 
 | ||||||
| export function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) { | export function itemToString<T extends string | number>(item?: ComboboxOption<T> | null) { | ||||||
|   if (item == null) { |   if (item == null) { | ||||||
|  | @ -26,60 +9,11 @@ export function itemToString<T extends string | number>(item?: ComboboxOption<T> | ||||||
|   return item.label ?? item.value.toString(); |   return item.label ?? item.value.toString(); | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| //TODO: Remove when MutliCombobox async has been merged
 |  | ||||||
| export function itemFilter<T extends string | number>(inputValue: string) { |  | ||||||
|   const lowerCasedInputValue = inputValue.toLowerCase(); |  | ||||||
| 
 |  | ||||||
|   return (item: ComboboxOption<T>) => { |  | ||||||
|     return ( |  | ||||||
|       !inputValue || |  | ||||||
|       item.label?.toLowerCase().includes(lowerCasedInputValue) || |  | ||||||
|       item.value?.toString().toLowerCase().includes(lowerCasedInputValue) || |  | ||||||
|       item.value.toString() === ALL_OPTION_VALUE |  | ||||||
|     ); |  | ||||||
|   }; |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| export function fuzzyFind<T extends string | number>( | export function fuzzyFind<T extends string | number>( | ||||||
|   options: Array<ComboboxOption<T>>, |   options: Array<ComboboxOption<T>>, | ||||||
|   haystack: string[], |   haystack: string[], | ||||||
|   needle: string |   needle: string | ||||||
| ) { | ) { | ||||||
|   let matches: Array<ComboboxOption<T>> = []; |   const indices = fuzzySearch(haystack, needle); | ||||||
| 
 |   return indices.map((idx) => options[idx]); | ||||||
|   if (needle === '') { |  | ||||||
|     matches = options; |  | ||||||
|   } |  | ||||||
|   // 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 |  | ||||||
|   ) { |  | ||||||
|     for (let i = 0; i < haystack.length; i++) { |  | ||||||
|       let item = haystack[i]; |  | ||||||
| 
 |  | ||||||
|       if (item.includes(needle)) { |  | ||||||
|         matches.push(options[i]); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|   // fuzzy search
 |  | ||||||
|   else { |  | ||||||
|     const [idxs, info, order] = uf.search(haystack, needle, outOfOrderLimit, rankThreshold); |  | ||||||
| 
 |  | ||||||
|     if (idxs?.length) { |  | ||||||
|       if (info && order) { |  | ||||||
|         matches = order.map((idx) => options[info.idx[idx]]); |  | ||||||
|       } else { |  | ||||||
|         matches = idxs.map((idx) => options[idx]); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   return matches; |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -3021,6 +3021,7 @@ __metadata: | ||||||
|     "@braintree/sanitize-url": "npm:7.0.1" |     "@braintree/sanitize-url": "npm:7.0.1" | ||||||
|     "@grafana/schema": "npm:12.1.0-pre" |     "@grafana/schema": "npm:12.1.0-pre" | ||||||
|     "@grafana/tsconfig": "npm:^2.0.0" |     "@grafana/tsconfig": "npm:^2.0.0" | ||||||
|  |     "@leeoniya/ufuzzy": "npm:1.0.18" | ||||||
|     "@rollup/plugin-node-resolve": "npm:16.0.0" |     "@rollup/plugin-node-resolve": "npm:16.0.0" | ||||||
|     "@types/d3-interpolate": "npm:^3.0.0" |     "@types/d3-interpolate": "npm:^3.0.0" | ||||||
|     "@types/history": "npm:4.7.11" |     "@types/history": "npm:4.7.11" | ||||||
|  | @ -3643,7 +3644,6 @@ __metadata: | ||||||
|     "@grafana/schema": "npm:12.1.0-pre" |     "@grafana/schema": "npm:12.1.0-pre" | ||||||
|     "@grafana/tsconfig": "npm:^2.0.0" |     "@grafana/tsconfig": "npm:^2.0.0" | ||||||
|     "@hello-pangea/dnd": "npm:18.0.1" |     "@hello-pangea/dnd": "npm:18.0.1" | ||||||
|     "@leeoniya/ufuzzy": "npm:1.0.18" |  | ||||||
|     "@monaco-editor/react": "npm:4.7.0" |     "@monaco-editor/react": "npm:4.7.0" | ||||||
|     "@popperjs/core": "npm:2.11.8" |     "@popperjs/core": "npm:2.11.8" | ||||||
|     "@react-aria/dialog": "npm:3.5.27" |     "@react-aria/dialog": "npm:3.5.27" | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue