add SearchByKMP

This commit is contained in:
midzer 2025-02-23 01:32:51 +01:00
commit b77846d865
8 changed files with 170 additions and 1 deletions

View file

@ -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.

View file

@ -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 [KnuthMorrisPratt 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`

View file

@ -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;
};

View file

@ -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;

View file

@ -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<T extends object>(config: Options): Searcher<T> {
if (searchFuse) {
return new SearchByFuse<T>(config);
}
if (searchKMP) {
return new SearchByKMP<T>(config);
}
return new SearchByPrefixFilter<T>(config);
}

80
src/scripts/search/kmp.ts Normal file
View file

@ -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<T extends object> implements Searcher<T> {
_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<T>[] {
const fields = this._fields;
if (!fields || !fields.length || !_needle) {
return [];
}
const needle = _needle.toLowerCase();
const results: SearchResult<T>[] = [];
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;
}
}

View file

@ -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 <search> 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);

View file

@ -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<SearchableShape>;
beforeEach(() => {
process.env.CHOICES_SEARCH_KMP = undefined;
searcher = new SearchByKMP<SearchableShape>(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<SearchableShape>;
beforeEach(() => {