mirror of
https://github.com/Choices-js/Choices.git
synced 2026-03-14 14:45:47 +01:00
add SearchByKMP
This commit is contained in:
parent
ac13edefb7
commit
b77846d865
8 changed files with 170 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
80
src/scripts/search/kmp.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue