• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

lucaong / minisearch / #567

18 Jul 2024 02:44PM UTC coverage: 98.521%. First build
#567

push

github

web-flow
Merge 78c879340 into 746f09134

222 of 232 branches covered (95.69%)

Branch coverage included in aggregate %.

644 of 647 relevant lines covered (99.54%)

735.23 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.4
/src/SearchableMap/SearchableMap.ts
1
/* eslint-disable no-labels */
2
import { TreeIterator, ENTRIES, KEYS, VALUES, LEAF } from './TreeIterator'
2✔
3
import fuzzySearch, { type FuzzyResults } from './fuzzySearch'
2✔
4
import type { RadixTree, Entry, Path } from './types'
5

6
/**
7
 * A class implementing the same interface as a standard JavaScript
8
 * [`Map`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map)
9
 * with string keys, but adding support for efficiently searching entries with
10
 * prefix or fuzzy search. This class is used internally by {@link MiniSearch}
11
 * as the inverted index data structure. The implementation is a radix tree
12
 * (compressed prefix tree).
13
 *
14
 * Since this class can be of general utility beyond _MiniSearch_, it is
15
 * exported by the `minisearch` package and can be imported (or required) as
16
 * `minisearch/SearchableMap`.
17
 *
18
 * @typeParam T  The type of the values stored in the map.
19
 */
20
export default class SearchableMap<T = any> {
2✔
21
  /**
22
   * @internal
23
   */
24
  _tree: RadixTree<T>
25

26
  /**
27
   * @internal
28
   */
29
  _prefix: string
30

31
  private _size: number | undefined = undefined
385✔
32

33
  /**
34
   * The constructor is normally called without arguments, creating an empty
35
   * map. In order to create a {@link SearchableMap} from an iterable or from an
36
   * object, check {@link SearchableMap.from} and {@link
37
   * SearchableMap.fromObject}.
38
   *
39
   * The constructor arguments are for internal use, when creating derived
40
   * mutable views of a map at a prefix.
41
   */
42
  constructor (tree: RadixTree<T> = new Map(), prefix = '') {
568✔
43
    this._tree = tree
385✔
44
    this._prefix = prefix
385✔
45
  }
46

47
  /**
48
   * Creates and returns a mutable view of this {@link SearchableMap},
49
   * containing only entries that share the given prefix.
50
   *
51
   * ### Usage:
52
   *
53
   * ```javascript
54
   * let map = new SearchableMap()
55
   * map.set("unicorn", 1)
56
   * map.set("universe", 2)
57
   * map.set("university", 3)
58
   * map.set("unique", 4)
59
   * map.set("hello", 5)
60
   *
61
   * let uni = map.atPrefix("uni")
62
   * uni.get("unique") // => 4
63
   * uni.get("unicorn") // => 1
64
   * uni.get("hello") // => undefined
65
   *
66
   * let univer = map.atPrefix("univer")
67
   * univer.get("unique") // => undefined
68
   * univer.get("universe") // => 2
69
   * univer.get("university") // => 3
70
   * ```
71
   *
72
   * @param prefix  The prefix
73
   * @return A {@link SearchableMap} representing a mutable view of the original
74
   * Map at the given prefix
75
   */
76
  atPrefix (prefix: string): SearchableMap<T> {
77
    if (!prefix.startsWith(this._prefix)) { throw new Error('Mismatched prefix') }
145✔
78

79
    const [node, path] = trackDown(this._tree, prefix.slice(this._prefix.length))
144✔
80

81
    if (node === undefined) {
144✔
82
      const [parentNode, key] = last(path)
99✔
83

84
      for (const k of parentNode!.keys()) {
99✔
85
        if (k !== LEAF && k.startsWith(key)) {
449✔
86
          const node = new Map()
12✔
87
          node.set(k.slice(key.length), parentNode!.get(k)!)
12✔
88
          return new SearchableMap(node, prefix)
12✔
89
        }
90
      }
91
    }
92

93
    return new SearchableMap<T>(node, prefix)
132✔
94
  }
95

96
  /**
97
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/clear
98
   */
99
  clear (): void {
100
    this._size = undefined
2✔
101
    this._tree.clear()
2✔
102
  }
103

104
  /**
105
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/delete
106
   * @param key  Key to delete
107
   */
108
  delete (key: string): void {
109
    this._size = undefined
622✔
110
    return remove(this._tree, key)
622✔
111
  }
112

113
  /**
114
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries
115
   * @return An iterator iterating through `[key, value]` entries.
116
   */
117
  entries () {
118
    return new TreeIterator(this, ENTRIES)
412✔
119
  }
120

121
  /**
122
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/forEach
123
   * @param fn  Iteration function
124
   */
125
  forEach (fn: (key: string, value: T, map: SearchableMap) => void): void {
126
    for (const [key, value] of this) {
1✔
127
      fn(key, value, this)
15✔
128
    }
129
  }
130

131
  /**
132
   * Returns a Map of all the entries that have a key within the given edit
133
   * distance from the search key. The keys of the returned Map are the matching
134
   * keys, while the values are two-element arrays where the first element is
135
   * the value associated to the key, and the second is the edit distance of the
136
   * key to the search key.
137
   *
138
   * ### Usage:
139
   *
140
   * ```javascript
141
   * let map = new SearchableMap()
142
   * map.set('hello', 'world')
143
   * map.set('hell', 'yeah')
144
   * map.set('ciao', 'mondo')
145
   *
146
   * // Get all entries that match the key 'hallo' with a maximum edit distance of 2
147
   * map.fuzzyGet('hallo', 2)
148
   * // => Map(2) { 'hello' => ['world', 1], 'hell' => ['yeah', 2] }
149
   *
150
   * // In the example, the "hello" key has value "world" and edit distance of 1
151
   * // (change "e" to "a"), the key "hell" has value "yeah" and edit distance of 2
152
   * // (change "e" to "a", delete "o")
153
   * ```
154
   *
155
   * @param key  The search key
156
   * @param maxEditDistance  The maximum edit distance (Levenshtein)
157
   * @return A Map of the matching keys to their value and edit distance
158
   */
159
  fuzzyGet (key: string, maxEditDistance: number): FuzzyResults<T> {
160
    return fuzzySearch<T>(this._tree, key, maxEditDistance)
141✔
161
  }
162

163
  /**
164
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/get
165
   * @param key  Key to get
166
   * @return Value associated to the key, or `undefined` if the key is not
167
   * found.
168
   */
169
  get (key: string): T | undefined {
170
    const node = lookup<T>(this._tree, key)
896✔
171
    return node !== undefined ? node.get(LEAF) : undefined
896✔
172
  }
173

174
  /**
175
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/has
176
   * @param key  Key
177
   * @return True if the key is in the map, false otherwise
178
   */
179
  has (key: string): boolean {
180
    const node = lookup(this._tree, key)
1,114✔
181
    return node !== undefined && node.has(LEAF)
1,114✔
182
  }
183

184
  /**
185
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/keys
186
   * @return An `Iterable` iterating through keys
187
   */
188
  keys () {
189
    return new TreeIterator(this, KEYS)
106✔
190
  }
191

192
  /**
193
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/set
194
   * @param key  Key to set
195
   * @param value  Value to associate to the key
196
   * @return The {@link SearchableMap} itself, to allow chaining
197
   */
198
  set (key: string, value: T): SearchableMap<T> {
199
    if (typeof key !== 'string') { throw new Error('key must be a string') }
813✔
200
    this._size = undefined
812✔
201
    const node = createPath(this._tree, key)
812✔
202
    node.set(LEAF, value)
812✔
203
    return this
812✔
204
  }
205

206
  /**
207
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/size
208
   */
209
  get size (): number {
210
    if (this._size) { return this._size }
226✔
211
    /** @ignore */
212
    this._size = 0
224✔
213

214
    const iter = this.entries()
224✔
215
    while (!iter.next().done) this._size! += 1
582✔
216

217
    return this._size
224✔
218
  }
219

220
  /**
221
   * Updates the value at the given key using the provided function. The function
222
   * is called with the current value at the key, and its return value is used as
223
   * the new value to be set.
224
   *
225
   * ### Example:
226
   *
227
   * ```javascript
228
   * // Increment the current value by one
229
   * searchableMap.update('somekey', (currentValue) => currentValue == null ? 0 : currentValue + 1)
230
   * ```
231
   *
232
   * If the value at the given key is or will be an object, it might not require
233
   * re-assignment. In that case it is better to use `fetch()`, because it is
234
   * faster.
235
   *
236
   * @param key  The key to update
237
   * @param fn  The function used to compute the new value from the current one
238
   * @return The {@link SearchableMap} itself, to allow chaining
239
   */
240
  update (key: string, fn: (value: T | undefined) => T): SearchableMap<T> {
241
    if (typeof key !== 'string') { throw new Error('key must be a string') }
3✔
242
    this._size = undefined
2✔
243
    const node = createPath(this._tree, key)
2✔
244
    node.set(LEAF, fn(node.get(LEAF)))
2✔
245
    return this
2✔
246
  }
247

248
  /**
249
   * Fetches the value of the given key. If the value does not exist, calls the
250
   * given function to create a new value, which is inserted at the given key
251
   * and subsequently returned.
252
   *
253
   * ### Example:
254
   *
255
   * ```javascript
256
   * const map = searchableMap.fetch('somekey', () => new Map())
257
   * map.set('foo', 'bar')
258
   * ```
259
   *
260
   * @param key  The key to update
261
   * @param defaultValue  A function that creates a new value if the key does not exist
262
   * @return The existing or new value at the given key
263
   */
264
  fetch (key: string, initial: () => T): T {
265
    if (typeof key !== 'string') { throw new Error('key must be a string') }
2,866!
266
    this._size = undefined
2,866✔
267
    const node = createPath(this._tree, key)
2,866✔
268

269
    let value = node.get(LEAF)
2,866✔
270
    if (value === undefined) {
2,866✔
271
      node.set(LEAF, value = initial())
1,781✔
272
    }
273

274
    return value
2,866✔
275
  }
276

277
  /**
278
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/values
279
   * @return An `Iterable` iterating through values.
280
   */
281
  values () {
282
    return new TreeIterator(this, VALUES)
3✔
283
  }
284

285
  /**
286
   * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/@@iterator
287
   */
288
  [Symbol.iterator] () {
289
    return this.entries()
83✔
290
  }
291

292
  /**
293
   * Creates a {@link SearchableMap} from an `Iterable` of entries
294
   *
295
   * @param entries  Entries to be inserted in the {@link SearchableMap}
296
   * @return A new {@link SearchableMap} with the given entries
297
   */
298
  static from<T = any> (entries: Iterable<Entry<T>> | Entry<T>[]) {
299
    const tree = new SearchableMap()
18✔
300
    for (const [key, value] of entries) {
18✔
301
      tree.set(key, value)
208✔
302
    }
303
    return tree
18✔
304
  }
305

306
  /**
307
   * Creates a {@link SearchableMap} from the iterable properties of a JavaScript object
308
   *
309
   * @param object  Object of entries for the {@link SearchableMap}
310
   * @return A new {@link SearchableMap} with the given entries
311
   */
312
  static fromObject<T = any> (object: { [key: string]: T }) {
313
    return SearchableMap.from<T>(Object.entries(object))
5✔
314
  }
315
}
316

317
const trackDown = <T = any>(tree: RadixTree<T> | undefined, key: string, path: Path<T> = []): [RadixTree<T> | undefined, Path<T>] => {
2✔
318
  if (key.length === 0 || tree == null) { return [tree, path] }
1,628✔
319

320
  for (const k of tree.keys()) {
862✔
321
    if (k !== LEAF && key.startsWith(k)) {
1,904✔
322
      path.push([tree, k]) // performance: update in place
761✔
323
      return trackDown(tree.get(k)!, key.slice(k.length), path)
761✔
324
    }
325
  }
326

327
  path.push([tree, key]) // performance: update in place
101✔
328
  return trackDown(undefined, '', path)
101✔
329
}
330

331
const lookup = <T = any>(tree: RadixTree<T>, key: string): RadixTree<T> | undefined => {
2✔
332
  if (key.length === 0 || tree == null) { return tree }
3,470✔
333

334
  for (const k of tree.keys()) {
2,371✔
335
    if (k !== LEAF && key.startsWith(k)) {
7,132✔
336
      return lookup(tree.get(k)!, key.slice(k.length))
1,460✔
337
    }
338
  }
339
}
340

341
// Create a path in the radix tree for the given key, and returns the deepest
342
// node. This function is in the hot path for indexing. It avoids unnecessary
343
// string operations and recursion for performance.
344
const createPath = <T = any>(node: RadixTree<T>, key: string): RadixTree<T> => {
2✔
345
  const keyLength = key.length
3,680✔
346

347
  outer: for (let pos = 0; node && pos < keyLength;) {
3,680✔
348
    for (const k of node.keys()) {
7,015✔
349
      // Check whether this key is a candidate: the first characters must match.
350
      if (k !== LEAF && key[pos] === k[0]) {
29,250✔
351
        const len = Math.min(keyLength - pos, k.length)
4,575✔
352

353
        // Advance offset to the point where key and k no longer match.
354
        let offset = 1
4,575✔
355
        while (offset < len && key[pos + offset] === k[offset]) ++offset
4,575✔
356

357
        const child = node.get(k)!
4,575✔
358
        if (offset === k.length) {
4,575✔
359
          // The existing key is shorter than the key we need to create.
360
          node = child
3,805✔
361
        } else {
362
          // Partial match: we need to insert an intermediate node to contain
363
          // both the existing subtree and the new node.
364
          const intermediate = new Map()
770✔
365
          intermediate.set(k.slice(offset), child)
770✔
366
          node.set(key.slice(pos, pos + offset), intermediate)
770✔
367
          node.delete(k)
770✔
368
          node = intermediate
770✔
369
        }
370

371
        pos += offset
4,575✔
372
        continue outer
4,575✔
373
      }
374
    }
375

376
    // Create a final child node to contain the final suffix of the key.
377
    const child = new Map()
2,440✔
378
    node.set(key.slice(pos), child)
2,440✔
379
    return child
2,440✔
380
  }
381

382
  return node
1,240✔
383
}
384

385
const remove = <T = any>(tree: RadixTree<T>, key: string): void => {
2✔
386
  const [node, path] = trackDown(tree, key)
622✔
387
  if (node === undefined) { return }
622✔
388
  node.delete(LEAF)
620✔
389

390
  if (node.size === 0) {
620✔
391
    cleanup(path)
582✔
392
  } else if (node.size === 1) {
38✔
393
    const [key, value] = node.entries().next().value
9✔
394
    merge(path, key, value)
9✔
395
  }
396
}
397

398
const cleanup = <T = any>(path: Path<T>): void => {
2✔
399
  if (path.length === 0) { return }
676✔
400

401
  const [node, key] = last(path)
572✔
402
  node!.delete(key)
572✔
403

404
  if (node!.size === 0) {
572✔
405
    cleanup(path.slice(0, -1))
94✔
406
  } else if (node!.size === 1) {
478✔
407
    const [key, value] = node!.entries().next().value
165✔
408
    if (key !== LEAF) {
165✔
409
      merge(path.slice(0, -1), key, value)
140✔
410
    }
411
  }
412
}
413

414
const merge = <T = any>(path: Path<T>, key: string, value: RadixTree<T>): void => {
2✔
415
  if (path.length === 0) { return }
149✔
416

417
  const [node, nodeKey] = last(path)
65✔
418
  node!.set(nodeKey + key, value)
65✔
419
  node!.delete(nodeKey)
65✔
420
}
421

422
const last = <T = any>(array: T[]): T => {
2✔
423
  return array[array.length - 1]
736✔
424
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc