Skip to content

Commit

Permalink
fix(libs): invalidate cache on collection change
Browse files Browse the repository at this point in the history
  • Loading branch information
ivangabriele committed Sep 28, 2023
1 parent 1664a59 commit 3d1d290
Show file tree
Hide file tree
Showing 12 changed files with 170 additions and 106 deletions.
Binary file not shown.
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@types/jabber": "1.2.0",
"@types/jest": "29.5.2",
"@types/node": "20.5.9",
"@types/object-hash": "3.0.4",
"@types/ramda": "0.28.23",
"@types/react": "18.2.21",
"@types/react-dom": "18.2.7",
Expand Down Expand Up @@ -121,6 +122,7 @@
"ky": "0.33.3",
"lint-staged": "13.2.2",
"lodash": "4.17.21",
"object-hash": "3.0.0",
"ol": "7.1.0",
"postcss": "8.4.27",
"prettier": "2.8.4",
Expand Down
40 changes: 40 additions & 0 deletions src/libs/CustomSearch/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { getHashFromCollection } from '../../utils/getHashFromCollection'

import type { CustomSearchCache, CustomSearchCacheRecord } from './types'
import type { AnyCollection } from '../../types'

/**
* We take advantage of the global JS scope to use this constant as a "singleton" cache
* to avoid re-normalizing and re-search-indexing each time `CustomSearch` is instanciated.
*
* This cache will only be used if the `cacheKey` option is set while instanciating `CustomSearch`.
*/
const FUSE_SEARCH_CACHE: CustomSearchCache = {}

export function findCacheRecord(
currentCollection: AnyCollection,
cacheKey: string | undefined,
withCacheInvalidation: boolean
): CustomSearchCacheRecord | undefined {
if (!cacheKey) {
return undefined
}

const cacheRecord = FUSE_SEARCH_CACHE[cacheKey]
if (!cacheRecord) {
return undefined
}

if (withCacheInvalidation) {
const currentCollectionHash = getHashFromCollection(currentCollection)
if (currentCollectionHash !== cacheRecord.originalCollectionHash) {
return undefined
}
}

return cacheRecord
}

export function storeCacheRecord(cacheKey: string, cacheRecord: CustomSearchCacheRecord) {
FUSE_SEARCH_CACHE[cacheKey] = cacheRecord
}
67 changes: 33 additions & 34 deletions src/libs/CustomSearch/index.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,16 @@
/* eslint-disable @typescript-eslint/naming-convention */

import diacritics from 'diacritics'
import Fuse from 'fuse.js'

import { findCacheRecord, storeCacheRecord } from './cache'
import { cleanCollectionDiacritics } from './utils/cleanCollectionDiacritics'
import { getHashFromCollection } from '../../utils/getHashFromCollection'

import type { CustomSearchKey, CustomSearchOptions } from './types'

/**
* We take advantage of the global JS scope to use this constant as a "singleton" cache
* to avoid re-normalizing and re-search-indexing each time `CustomSearch` is instanciated.
*
* This cache will only be used if the `cacheKey` option is set while instanciating `CustomSearch`.
*/
const FUSE_SEARCH_CACHE: Record<
string,
{
fuseSearchIndex: any
normalizedCollection: any
originalCollection: any
}
> = {}
import type { CustomSearchCacheRecord, CustomSearchKey, CustomSearchOptions } from './types'
import type { AnyObject } from '../../types'

export class CustomSearch<T extends Record<string, any> = Record<string, any>> {
export class CustomSearch<T extends AnyObject = AnyObject> {
#originalCollection: T[]
#fuse: Fuse<T>
/** See {@link CustomSearchOptions.isDiacriticSensitive}. */
Expand All @@ -37,40 +27,49 @@ export class CustomSearch<T extends Record<string, any> = Record<string, any>> {
isDiacriticSensitive = false,
isStrict = false,
shouldIgnoreLocation = true,
threshold = 0.4
threshold = 0.4,
withCacheInvalidation = false
}: CustomSearchOptions = {}
) {
const maybeCache = cacheKey ? FUSE_SEARCH_CACHE[cacheKey] : undefined
// eslint-disable-next-line no-nested-ternary
const normalizedCollection: T[] = maybeCache
? maybeCache.normalizedCollection
: isDiacriticSensitive
? collection
: cleanCollectionDiacritics(collection, keys)
// Will be `undefined` in any of those cases:
// - no cache key was provided
// - the cache record doesn't exist
// - the cache record is invalidated because the collection hash has changed IF `withCacheInvalidation` is `true`
const maybeCacheRecord = findCacheRecord(collection, cacheKey, withCacheInvalidation)

const normalizedCollection: T[] =
maybeCacheRecord?.normalizedCollection ?? isDiacriticSensitive
? collection
: cleanCollectionDiacritics(collection, keys)

this.#fuse = new Fuse(
normalizedCollection,
/* eslint-disable @typescript-eslint/naming-convention */
{
ignoreLocation: shouldIgnoreLocation,
isCaseSensitive,
keys,
threshold,
useExtendedSearch: isStrict
},
/* eslint-enable @typescript-eslint/naming-convention */
maybeCache ? Fuse.parseIndex<T>(maybeCache.fuseSearchIndex) : undefined
maybeCacheRecord ? Fuse.parseIndex<T>(maybeCacheRecord.fuseSearchIndex) : undefined
)
this.#isDiacriticSensitive = isDiacriticSensitive
this.#isStrict = isStrict
this.#originalCollection = maybeCache ? maybeCache.originalCollection : collection
this.#originalCollection = collection

if (cacheKey && !maybeCache) {
FUSE_SEARCH_CACHE[cacheKey] = {
// If a cache key was provided
// and the cache record was either nonexistent or invalidated,
if (cacheKey && !maybeCacheRecord) {
// we create a new cache record
const newCacheRecord: CustomSearchCacheRecord = {
fuseSearchIndex: this.#fuse.getIndex(),
normalizedCollection,
originalCollection: collection
originalCollection: collection,
originalCollectionHash: getHashFromCollection(collection)
}

// and store it
storeCacheRecord(cacheKey, newCacheRecord)
}
}

Expand All @@ -81,7 +80,7 @@ export class CustomSearch<T extends Record<string, any> = Record<string, any>> {
* @param limit Denotes the max number of returned search results
* @returns A list of matching items
*/
public find(query: string, limit?: number): T[] {
find(query: string, limit?: number): T[] {
const normalizedQuery = (this.#isDiacriticSensitive ? query : diacritics.remove(query)).trim()

// Here we use Fuse.js `useExtendedSearch` option to avoid fuzziness
Expand All @@ -101,7 +100,7 @@ export class CustomSearch<T extends Record<string, any> = Record<string, any>> {
this.#fuse
.search(extendedQuery, limit ? { limit } : undefined)
// We remap to the original collection since the normalized collection can have some accents removed
// (because of the internal diacritic-less normalization)
// (because of the internal `CustomSearch` diacritic-less normalization)
.map(({ refIndex }) => this.#originalCollection[refIndex] as T)
)
}
Expand Down
17 changes: 17 additions & 0 deletions src/libs/CustomSearch/types.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
import type { AnyCollection } from '../../types'
import type Fuse from 'fuse.js'

export type CustomSearchCache = Record<string, CustomSearchCacheRecord>
export type CustomSearchCacheRecord = {
fuseSearchIndex: any
normalizedCollection: AnyCollection
originalCollection: AnyCollection
originalCollectionHash: string
}

export type CustomSearchKey<T> = string | Fuse.FuseOptionKeyObject<T>

export type CustomSearchOptions = Partial<{
/** Cache search index to avoid Must be unique in the entire application. */
cacheKey: string | undefined

/**
* Indicates whether comparisons should be case sensitive.
*
* @default false
* @see https://fusejs.io/api/options.html#iscasesensitive
*/
isCaseSensitive: boolean

/** Indicates whether comparisons should be diacritic (= accent) sensitive. */
isDiacriticSensitive: boolean

/**
* Use strict keywords matching, disabling fuzziness.
*
Expand All @@ -23,6 +35,7 @@ export type CustomSearchOptions = Partial<{
* instead of returning both by default (`false`).
*/
isStrict: boolean

/**
* By default, location is set to 0 and distance to 100
* When isStrict is false, for something to be considered a match, it would have to be within
Expand All @@ -33,6 +46,7 @@ export type CustomSearchOptions = Partial<{
* @see https://www.fusejs.io/concepts/scoring-theory.html#scoring-theory
*/
shouldIgnoreLocation: boolean

/**
* At what point does the match algorithm give up.
*
Expand All @@ -44,4 +58,7 @@ export type CustomSearchOptions = Partial<{
* @see https://fusejs.io/api/options.html#threshold
*/
threshold: number

/** Invalidate cached index when the collection changes. */
withCacheInvalidation: boolean
}>
3 changes: 2 additions & 1 deletion src/libs/CustomSearch/utils/cleanCollectionDiacritics.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import diacritics from 'diacritics'
import { flow, get, update } from 'lodash/fp'

import type { AnyObject } from '../../../types'
import type { CustomSearchKey } from '../types'

/**
Expand All @@ -27,7 +28,7 @@ import type { CustomSearchKey } from '../types'
* // => `{ "name": "aerosol", "description": "Un aérosol.", author: { name: 'Camille Hervé' }`
* ```
*/
export function cleanCollectionDiacritics<T extends Record<string, any> = Record<string, any>>(
export function cleanCollectionDiacritics<T extends AnyObject = AnyObject>(
collection: T[],
keys: Array<CustomSearchKey<T>>
): T[] {
Expand Down
42 changes: 42 additions & 0 deletions src/libs/__tests__/CustomSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,46 @@ describe('libs/CustomSearch.find()', () => {
expect(result).toHaveLength(2)
expect(result).toMatchObject([{ name: 'Avec Majuscule' }, { name: 'sans majuscule' }])
})

it('should NOT invalidate cache without `withCacheInvalidation`', () => {
const firstCollection = [{ name: 'abc' }, { name: 'def' }]
const keys = ['name']
const options = {
cacheKey: 'TEST_CACHE_KEY'
}

const firstCustomSearch = new CustomSearch(firstCollection, keys, options)
const firstResult = firstCustomSearch.find('ghi')

expect(firstResult).toHaveLength(0)

const secondCollection = [{ name: 'abc' }, { name: 'def' }, { name: 'ghi' }]

const secondCustomSearch = new CustomSearch(secondCollection, keys, options)
const secondResult = secondCustomSearch.find('ghi')

expect(secondResult).toHaveLength(0)
})

it('should invalidate cache when enabling `withCacheInvalidation`', () => {
const firstCollection = [{ name: 'abc' }, { name: 'def' }]
const keys = ['name']
const options = {
cacheKey: 'TEST_CACHE_KEY',
withCacheInvalidation: true
}

const firstCustomSearch = new CustomSearch(firstCollection, keys, options)
const firstResult = firstCustomSearch.find('ghi')

expect(firstResult).toHaveLength(0)

const secondCollection = [{ name: 'abc' }, { name: 'def' }, { name: 'ghi' }]

const secondCustomSearch = new CustomSearch(secondCollection, keys, options)
const secondResult = secondCustomSearch.find('ghi')

expect(secondResult).toHaveLength(1)
expect(secondResult).toMatchObject([{ name: 'ghi' }])
})
})
3 changes: 3 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@ export type UndefineExceptArrays<T> = {
// -----------------------------------------------------------------------------
// Private types

export type AnyCollection = AnyObject[]
export type AnyObject = Record<string, any>

export type Native = boolean | null | number | string | undefined
export type NativeAny = boolean | NativeArray | NativeObject | null | number | string | undefined
export type NativeArray = Array<NativeAny>
Expand Down
28 changes: 2 additions & 26 deletions src/utils/__tests__/getHashFromCollection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,7 @@ describe('utils/getHashFromCollection()', () => {
expect(firstResult).not.toBe(secondResult)
})

it('should return consistent hashes regardless of item order when using `sortingKey`', () => {
const firstCollection = [
{ id: 2, name: 'Item B' },
{ id: 1, name: 'Item A' }
]
const secondCollection = [
{ id: 1, name: 'Item A' },
{ id: 2, name: 'Item B' }
]

const firstResult = getHashFromCollection(firstCollection, 'id')
const secondResult = getHashFromCollection(secondCollection, 'id')

expect(firstResult).toBe(secondResult)
})

it('should return different hashes based on item order when no `sortingKey` is provided', () => {
it('should return consistent hashes regardless of item order', () => {
const firstCollection = [
{ id: 2, name: 'Item B' },
{ id: 1, name: 'Item A' }
Expand All @@ -58,14 +42,6 @@ describe('utils/getHashFromCollection()', () => {
const firstResult = getHashFromCollection(firstCollection)
const secondResult = getHashFromCollection(secondCollection)

expect(firstResult).not.toBe(secondResult)
})

it('should return "0" for an empty collection', () => {
const collection = []

const result = getHashFromCollection(collection)

expect(result).toBe('0')
expect(firstResult).toBe(secondResult)
})
})
Loading

0 comments on commit 3d1d290

Please sign in to comment.