diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a656dce3..741e6a3b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,14 @@ The following build flags are supported via environment variables: npm run js:watch -- --environment CHOICES_SEARCH_FUSE:basic ``` +### CHOICES_SEARCH_KMP +**Values:**: **"1" / "0" ** +**Usage:** High performance `indexOf`-like search algorithm. +**Example**: +``` +npm run js:watch -- --environment CHOICES_SEARCH_KMP:1 +``` + ### CHOICES_CAN_USE_DOM **Values:**: **"1" / "0" ** **Usage:** Indicates if DOM methods are supported in the global namespace. Useful if importing into DOM or the e2e tests without a DOM implementation available. diff --git a/README.md b/README.md index 5474b88f..00867b7b 100644 --- a/README.md +++ b/README.md @@ -1329,6 +1329,12 @@ The pre-built bundles these features set, and tree shaking uses the non-used par Fuse.js support a `full`/`basic` profile. `full` adds additional logic operations, which aren't used by default with Choices. The `null` option drops Fuse.js as a dependency and instead uses a simple prefix only search feature. +#### CHOICES_SEARCH_KMP +**Values:** `1` / `0` +**Default:** `0` + +If `CHOICES_SEARCH_FUSE` is `null`, this enables an `indexOf`-like [Knuth–Morris–Pratt algorithm](https://en.wikipedia.org/wiki/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm). Useful for very large data sets, without fuzzy searching. + #### CHOICES_CAN_USE_DOM **Values:** `1` / `0` **Default:** `1` diff --git a/public/types/src/scripts/interfaces/build-flags.d.ts b/public/types/src/scripts/interfaces/build-flags.d.ts index c803580a..cc6312df 100644 --- a/public/types/src/scripts/interfaces/build-flags.d.ts +++ b/public/types/src/scripts/interfaces/build-flags.d.ts @@ -1,9 +1,11 @@ export declare const canUseDom: boolean; export declare const searchFuse: string | undefined; +export declare const searchKMP: string | undefined; /** * These are not directly used, as an exported object (even as const) will prevent tree-shake away code paths */ export declare const BuildFlags: { readonly searchFuse: string | undefined; + readonly searchKMP: string | undefined; readonly canUseDom: boolean; }; diff --git a/src/scripts/interfaces/build-flags.ts b/src/scripts/interfaces/build-flags.ts index 68fea0d7..49ada2c1 100644 --- a/src/scripts/interfaces/build-flags.ts +++ b/src/scripts/interfaces/build-flags.ts @@ -4,6 +4,7 @@ export const canUseDom: boolean = : !!(typeof document !== 'undefined' && document.createElement); export const searchFuse: string | undefined = process.env.CHOICES_SEARCH_FUSE; +export const searchKMP: string | undefined = process.env.CHOICES_SEARCH_KMP; /** * These are not directly used, as an exported object (even as const) will prevent tree-shake away code paths @@ -11,5 +12,6 @@ export const searchFuse: string | undefined = process.env.CHOICES_SEARCH_FUSE; export const BuildFlags = { searchFuse, + searchKMP, canUseDom, } as const; diff --git a/src/scripts/search/index.ts b/src/scripts/search/index.ts index 7f84252c..388404ee 100644 --- a/src/scripts/search/index.ts +++ b/src/scripts/search/index.ts @@ -2,12 +2,16 @@ import { Options } from '../interfaces'; import { Searcher } from '../interfaces/search'; import { SearchByPrefixFilter } from './prefix-filter'; import { SearchByFuse } from './fuse'; -import { searchFuse } from '../interfaces/build-flags'; +import { SearchByKMP } from './kmp'; +import { searchFuse, searchKMP } from '../interfaces/build-flags'; export function getSearcher(config: Options): Searcher { if (searchFuse) { return new SearchByFuse(config); } + if (searchKMP) { + return new SearchByKMP(config); + } return new SearchByPrefixFilter(config); } diff --git a/src/scripts/search/kmp.ts b/src/scripts/search/kmp.ts new file mode 100644 index 00000000..4fdb2ce7 --- /dev/null +++ b/src/scripts/search/kmp.ts @@ -0,0 +1,80 @@ +import { Options } from '../interfaces'; +import { Searcher, SearchResult } from '../interfaces/search'; + +function kmpSearch(pattern: string, text: string): number { + if (pattern.length == 0) return 0; // Immediate match + + // Compute longest suffix-prefix table + var lsp = [0]; // Base case + for (var i = 1; i < pattern.length; i++) { + var j = lsp[i - 1]; // Start by assuming we're extending the previous LSP + while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) j = lsp[j - 1]; + if (pattern.charAt(i) == pattern.charAt(j)) j++; + lsp.push(j); + } + + // Walk through text string + var j = 0; // Number of chars matched in pattern + for (var i = 0; i < text.length; i++) { + while (j > 0 && text.charAt(i) != pattern.charAt(j)) j = lsp[j - 1]; // Fall back in the pattern + if (text.charAt(i) == pattern.charAt(j)) { + j++; // Next char matched, increment position + if (j == pattern.length) return i - (j - 1); + } + } + return -1; // Not found +} + +export class SearchByKMP implements Searcher { + _fields: string[]; + _limit: number; + + _haystack: T[] = []; + + constructor(config: Options) { + this._fields = config.searchFields; + this._limit = config.searchResultLimit; + } + + index(data: T[]): void { + this._haystack = data; + } + + reset(): void { + this._haystack = []; + } + + isEmptyIndex(): boolean { + return !this._haystack.length; + } + + search(_needle: string): SearchResult[] { + const fields = this._fields; + if (!fields || !fields.length || !_needle) { + return []; + } + const needle = _needle.toLowerCase(); + + const results: SearchResult[] = []; + + let count = 0; + for (let i = 0, j = this._haystack.length; i < j; i++) { + const obj = this._haystack[i]; + for (let k = 0, l = this._fields.length; k < l; k++) { + const field = this._fields[k]; + if (field in obj && kmpSearch(needle, (obj[field] as string).toLowerCase()) != -1) { + results.push({ + item: obj[field], + score: count, + rank: count + 1 + }); + if (++count === this._limit) { + break; + } + } + } + } + + return results; + } +} diff --git a/test/scripts/choices.test.ts b/test/scripts/choices.test.ts index 6a485813..741ed809 100644 --- a/test/scripts/choices.test.ts +++ b/test/scripts/choices.test.ts @@ -8,6 +8,7 @@ import { removeItem } from '../../src/scripts/actions/items'; import templates from '../../src/scripts/templates'; import { ChoiceFull } from '../../src/scripts/interfaces/choice-full'; import { SearchByFuse } from '../../src/scripts/search/fuse'; +import { SearchByKMP } from '../../src/scripts/search/kmp'; import { SearchByPrefixFilter } from '../../src/scripts/search/prefix-filter'; chai.use(sinonChai); @@ -2014,6 +2015,50 @@ describe('choices', () => { })); }); + describe('kmp', () => { + beforeEach(() => { + instance._searcher = new SearchByKMP(instance.config); + }); + it('details are passed', () => + new Promise((done) => { + const query = 'This is a query & a "test" with characters that should not be sanitised.'; + + instance.input.value = query; + instance.input.focus(); + instance.passedElement.element.addEventListener( + 'search', + (event) => { + expect(event.detail).to.contains({ + value: query, + resultCount: 0, + }); + done(true); + }, + { once: true }, + ); + + instance._onKeyUp({ target: null, keyCode: null }); + instance._onInput({ target: null }); + })); + + it('is fired with a searchFloor of 0', () => + new Promise((done) => { + instance.config.searchFloor = 0; + instance.input.value = 'qwerty'; + instance.input.focus(); + instance.passedElement.element.addEventListener('search', (event) => { + expect(event.detail).to.contains({ + value: instance.input.value, + resultCount: 0, + }); + done(true); + }); + + instance._onKeyUp({ target: null, keyCode: null }); + instance._onInput({ target: null }); + })); + }); + describe('prefix-filter', () => { beforeEach(() => { instance._searcher = new SearchByPrefixFilter(instance.config); diff --git a/test/scripts/search/index.test.ts b/test/scripts/search/index.test.ts index de5eb043..bd668d6c 100644 --- a/test/scripts/search/index.test.ts +++ b/test/scripts/search/index.test.ts @@ -3,6 +3,7 @@ import { beforeEach } from 'vitest'; import { DEFAULT_CONFIG } from '../../../src'; import { cloneObject } from '../../../src/scripts/lib/utils'; import { SearchByFuse } from '../../../src/scripts/search/fuse'; +import { SearchByKMP } from '../../../src/scripts/search/kmp'; import { SearchByPrefixFilter } from '../../../src/scripts/search/prefix-filter'; export interface SearchableShape { @@ -100,6 +101,27 @@ describe('search', () => { }); }); + describe('kmp', () => { + let searcher: SearchByKMP; + beforeEach(() => { + process.env.CHOICES_SEARCH_KMP = undefined; + searcher = new SearchByKMP(options); + searcher.index(haystack); + }); + it('empty result', () => { + const results = searcher.search(''); + expect(results.length).eq(0); + }); + it('label prefix', () => { + const results = searcher.search('label'); + expect(results.length).eq(haystack.length); + }); + it('label suffix', () => { + const results = searcher.search(`${haystack.length - 1}`); + expect(results.length).eq(0); + }); + }); + describe('prefix-filter', () => { let searcher: SearchByPrefixFilter; beforeEach(() => {