Skip to content

Commit bf2cfb9

Browse files
authored
Merge pull request #1277 from midzer/addSearchByKMP
add SearchByKMP
2 parents c2970f0 + 3361a71 commit bf2cfb9

File tree

10 files changed

+190
-2
lines changed

10 files changed

+190
-2
lines changed

CONTRIBUTING.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ The following build flags are supported via environment variables:
3939
npm run js:watch -- --environment CHOICES_SEARCH_FUSE:basic
4040
```
4141

42+
### CHOICES_SEARCH_KMP
43+
**Values:**: **"1" / "0" **
44+
**Usage:** High performance `indexOf`-like search algorithm.
45+
**Example**:
46+
```
47+
npm run js:watch -- --environment CHOICES_SEARCH_KMP:1
48+
```
49+
4250
### CHOICES_CAN_USE_DOM
4351
**Values:**: **"1" / "0" **
4452
**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.

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,12 @@ The pre-built bundles these features set, and tree shaking uses the non-used par
13311331
13321332
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.
13331333
1334+
#### CHOICES_SEARCH_KMP
1335+
**Values:** `1` / `0`
1336+
**Default:** `0`
1337+
1338+
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.
1339+
13341340
#### CHOICES_CAN_USE_DOM
13351341
**Values:** `1` / `0`
13361342
**Default:** `1`
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
export declare const canUseDom: boolean;
22
export declare const searchFuse: string | undefined;
3+
export declare const searchKMP: boolean;
34
/**
45
* These are not directly used, as an exported object (even as const) will prevent tree-shake away code paths
56
*/
67
export declare const BuildFlags: {
78
readonly searchFuse: string | undefined;
9+
readonly searchKMP: boolean;
810
readonly canUseDom: boolean;
911
};
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Options } from '../interfaces';
2+
import { Searcher, SearchResult } from '../interfaces/search';
3+
export declare class SearchByKMP<T extends object> implements Searcher<T> {
4+
_fields: string[];
5+
_haystack: T[];
6+
constructor(config: Options);
7+
index(data: T[]): void;
8+
reset(): void;
9+
isEmptyIndex(): boolean;
10+
search(_needle: string): SearchResult<T>[];
11+
}

scripts/rollup.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const pckg = require('../package.json');
1111

1212
const buildFeatures = {
1313
CHOICES_SEARCH_FUSE: "full", // "basic" / "null"
14+
CHOICES_SEARCH_KMP: "0", // "1"
1415
CHOICES_CAN_USE_DOM: "1", // "0"
1516
}
1617

src/scripts/interfaces/build-flags.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ export const canUseDom: boolean =
44
: !!(typeof document !== 'undefined' && document.createElement);
55

66
export const searchFuse: string | undefined = process.env.CHOICES_SEARCH_FUSE;
7+
export const searchKMP: boolean = process.env.CHOICES_SEARCH_KMP === '1';
78

89
/**
910
* These are not directly used, as an exported object (even as const) will prevent tree-shake away code paths
1011
*/
1112

1213
export const BuildFlags = {
1314
searchFuse,
15+
searchKMP,
1416
canUseDom,
1517
} as const;

src/scripts/search/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ import { Options } from '../interfaces';
22
import { Searcher } from '../interfaces/search';
33
import { SearchByPrefixFilter } from './prefix-filter';
44
import { SearchByFuse } from './fuse';
5-
import { searchFuse } from '../interfaces/build-flags';
5+
import { SearchByKMP } from './kmp';
6+
import { searchFuse, searchKMP } from '../interfaces/build-flags';
67

78
export function getSearcher<T extends object>(config: Options): Searcher<T> {
8-
if (searchFuse) {
9+
if (searchFuse && !searchKMP) {
910
return new SearchByFuse<T>(config);
1011
}
12+
if (searchKMP) {
13+
return new SearchByKMP<T>(config);
14+
}
1115

1216
return new SearchByPrefixFilter<T>(config);
1317
}

src/scripts/search/kmp.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { Options } from '../interfaces';
2+
import { Searcher, SearchResult } from '../interfaces/search';
3+
4+
function kmpSearch(pattern: string, text: string): number {
5+
if (pattern.length === 0) {
6+
return 0; // Immediate match
7+
}
8+
9+
// Compute longest suffix-prefix table
10+
const lsp = [0]; // Base case
11+
for (let i = 1; i < pattern.length; i++) {
12+
let j = lsp[i - 1]; // Start by assuming we're extending the previous LSP
13+
while (j > 0 && pattern.charAt(i) !== pattern.charAt(j)) {
14+
j = lsp[j - 1];
15+
}
16+
if (pattern.charAt(i) === pattern.charAt(j)) {
17+
j++;
18+
}
19+
lsp.push(j);
20+
}
21+
22+
// Walk through text string
23+
let j = 0; // Number of chars matched in pattern
24+
for (let i = 0; i < text.length; i++) {
25+
while (j > 0 && text.charAt(i) !== pattern.charAt(j)) {
26+
j = lsp[j - 1]; // Fall back in the pattern
27+
}
28+
if (text.charAt(i) === pattern.charAt(j)) {
29+
j++; // Next char matched, increment position
30+
if (j === pattern.length) {
31+
return i - (j - 1);
32+
}
33+
}
34+
}
35+
36+
return -1; // Not found
37+
}
38+
39+
export class SearchByKMP<T extends object> implements Searcher<T> {
40+
_fields: string[];
41+
42+
_haystack: T[] = [];
43+
44+
constructor(config: Options) {
45+
this._fields = config.searchFields;
46+
}
47+
48+
index(data: T[]): void {
49+
this._haystack = data;
50+
}
51+
52+
reset(): void {
53+
this._haystack = [];
54+
}
55+
56+
isEmptyIndex(): boolean {
57+
return !this._haystack.length;
58+
}
59+
60+
search(_needle: string): SearchResult<T>[] {
61+
const fields = this._fields;
62+
if (!fields || !fields.length || !_needle) {
63+
return [];
64+
}
65+
const needle = _needle.toLowerCase();
66+
67+
const results: SearchResult<T>[] = [];
68+
69+
let count = 0;
70+
for (let i = 0, j = this._haystack.length; i < j; i++) {
71+
const obj = this._haystack[i];
72+
for (let k = 0, l = this._fields.length; k < l; k++) {
73+
const field = this._fields[k];
74+
if (field in obj && kmpSearch(needle, (obj[field] as string).toLowerCase()) !== -1) {
75+
results.push({
76+
item: obj[field],
77+
score: count,
78+
rank: count + 1,
79+
});
80+
count++;
81+
}
82+
}
83+
}
84+
85+
return results;
86+
}
87+
}

test/scripts/choices.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { removeItem } from '../../src/scripts/actions/items';
88
import templates from '../../src/scripts/templates';
99
import { ChoiceFull } from '../../src/scripts/interfaces/choice-full';
1010
import { SearchByFuse } from '../../src/scripts/search/fuse';
11+
import { SearchByKMP } from '../../src/scripts/search/kmp';
1112
import { SearchByPrefixFilter } from '../../src/scripts/search/prefix-filter';
1213

1314
chai.use(sinonChai);
@@ -2014,6 +2015,50 @@ describe('choices', () => {
20142015
}));
20152016
});
20162017

2018+
describe('kmp', () => {
2019+
beforeEach(() => {
2020+
instance._searcher = new SearchByKMP(instance.config);
2021+
});
2022+
it('details are passed', () =>
2023+
new Promise((done) => {
2024+
const query = 'This is a <search> query & a "test" with characters that should not be sanitised.';
2025+
2026+
instance.input.value = query;
2027+
instance.input.focus();
2028+
instance.passedElement.element.addEventListener(
2029+
'search',
2030+
(event) => {
2031+
expect(event.detail).to.contains({
2032+
value: query,
2033+
resultCount: 0,
2034+
});
2035+
done(true);
2036+
},
2037+
{ once: true },
2038+
);
2039+
2040+
instance._onKeyUp({ target: null, keyCode: null });
2041+
instance._onInput({ target: null });
2042+
}));
2043+
2044+
it('is fired with a searchFloor of 0', () =>
2045+
new Promise((done) => {
2046+
instance.config.searchFloor = 0;
2047+
instance.input.value = 'qwerty';
2048+
instance.input.focus();
2049+
instance.passedElement.element.addEventListener('search', (event) => {
2050+
expect(event.detail).to.contains({
2051+
value: instance.input.value,
2052+
resultCount: 0,
2053+
});
2054+
done(true);
2055+
});
2056+
2057+
instance._onKeyUp({ target: null, keyCode: null });
2058+
instance._onInput({ target: null });
2059+
}));
2060+
});
2061+
20172062
describe('prefix-filter', () => {
20182063
beforeEach(() => {
20192064
instance._searcher = new SearchByPrefixFilter(instance.config);

test/scripts/search/index.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { beforeEach } from 'vitest';
33
import { DEFAULT_CONFIG } from '../../../src';
44
import { cloneObject } from '../../../src/scripts/lib/utils';
55
import { SearchByFuse } from '../../../src/scripts/search/fuse';
6+
import { SearchByKMP } from '../../../src/scripts/search/kmp';
67
import { SearchByPrefixFilter } from '../../../src/scripts/search/prefix-filter';
78

89
export interface SearchableShape {
@@ -100,6 +101,27 @@ describe('search', () => {
100101
});
101102
});
102103

104+
describe('kmp', () => {
105+
let searcher: SearchByKMP<SearchableShape>;
106+
beforeEach(() => {
107+
process.env.CHOICES_SEARCH_KMP = '1';
108+
searcher = new SearchByKMP<SearchableShape>(options);
109+
searcher.index(haystack);
110+
});
111+
it('empty result', () => {
112+
const results = searcher.search('');
113+
expect(results.length).eq(0);
114+
});
115+
it('label prefix', () => {
116+
const results = searcher.search('label');
117+
expect(results.length).eq(haystack.length);
118+
});
119+
it('label suffix', () => {
120+
const results = searcher.search(`${haystack.length - 1}`);
121+
expect(results.length).eq(2);
122+
});
123+
});
124+
103125
describe('prefix-filter', () => {
104126
let searcher: SearchByPrefixFilter<SearchableShape>;
105127
beforeEach(() => {

0 commit comments

Comments
 (0)