nhb-toolbox
Version:
A versatile collection of smart, efficient, and reusable utility functions, classes and types for everyday development needs.
193 lines (192 loc) • 7.42 kB
JavaScript
import { isFunction } from '../guards/non-primitives.js';
import { isString } from '../guards/primitives.js';
export class Finder {
static #DEFAULT_TTL = 1000 * 60 * 5;
#cachedResult = new Map();
#sortedCache = new Map();
#ttl;
#items;
constructor(data, ttl = Finder.#DEFAULT_TTL) {
this.#ttl = ttl;
this.#items = isFunction(data) ? data() : data;
}
clearCache(key) {
if (key) {
this.#cachedResult.delete(key);
}
else {
this.#cachedResult.clear();
}
}
findAll(matcher, keySelector, options) {
const { fuzzy = false, needSorting = true, cacheKey = 'finder-cache', forceBinary = false, caseInsensitive = true, data, } = options ?? {};
const source = isFunction(data) ? data() : (data ?? this.#items);
if (!source?.length)
return [];
const rawGetKey = typeof keySelector === 'function'
? keySelector
: (item) => item[keySelector];
const getKey = Finder.#createMemoizedKeyGetter(rawGetKey);
const normalizedMatcher = caseInsensitive && isString(matcher) ? matcher.toLowerCase() : matcher;
if (cacheKey) {
const entry = this.#cachedResult.get(cacheKey);
if (entry && Date.now() - entry.timestamp < this.#ttl) {
return entry.result;
}
else {
this.#cachedResult.delete(cacheKey);
}
}
let results = [];
if (source.length < 100 && !forceBinary) {
results = source.filter((item) => {
const key = getKey(item);
const value = caseInsensitive && isString(key) ? key.toLowerCase() : key;
return value === normalizedMatcher;
});
}
else {
const sorted = needSorting ? this.#sortAndCache(source, getKey, cacheKey) : source;
const firstMatch = this.binarySearch(sorted, normalizedMatcher, getKey, caseInsensitive);
if (firstMatch) {
const baseKey = getKey(firstMatch);
const base = caseInsensitive && isString(baseKey) ? baseKey.toLowerCase() : baseKey;
results = sorted.filter((item) => {
const key = getKey(item);
const value = caseInsensitive && isString(key) ? key.toLowerCase() : key;
return value === base;
});
}
}
if (!results.length && fuzzy && isString(normalizedMatcher)) {
results = source.filter((item) => {
const rawKey = getKey(item);
const key = caseInsensitive && isString(rawKey) ? rawKey.toLowerCase() : String(rawKey);
return this.#match(key, normalizedMatcher);
});
}
if (cacheKey) {
this.#cachedResult.set(cacheKey, {
result: results,
timestamp: Date.now(),
});
}
return results;
}
findOne(matcher, keySelector, options) {
const { fuzzy = false, needSorting = true, cacheKey = 'finder-cache', forceBinary = false, caseInsensitive = true, data, } = options ?? {};
const source = isFunction(data) ? data() : (data ?? this.#items);
if (!source?.length)
return undefined;
const rawGetKey = typeof keySelector === 'function'
? keySelector
: (item) => item[keySelector];
const getKey = Finder.#createMemoizedKeyGetter(rawGetKey);
const normalizedMatcher = caseInsensitive && isString(matcher) ? matcher.toLowerCase() : matcher;
if (cacheKey) {
const entry = this.#cachedResult.get(cacheKey);
if (entry && Date.now() - entry.timestamp < this.#ttl) {
return entry.result[0];
}
else {
this.#cachedResult.delete(cacheKey);
}
}
let result;
if (source?.length < 100 && !forceBinary) {
result = source?.find((item) => {
const key = getKey(item);
const value = caseInsensitive && isString(key) ? key.toLowerCase() : key;
return value === normalizedMatcher;
});
}
else {
result = this.binarySearch(needSorting ? this.#sortAndCache(source, getKey, cacheKey) : source, normalizedMatcher, getKey, caseInsensitive);
}
if (!result && fuzzy && isString(normalizedMatcher)) {
return this.fuzzySearch(source, normalizedMatcher, getKey, caseInsensitive);
}
if (cacheKey && result) {
this.#cachedResult.set(cacheKey, {
result: [result],
timestamp: Date.now(),
});
}
return result;
}
async findAllAsync(supplier, matcher, keySelector, options) {
const items = await supplier();
return this.findAll(matcher, keySelector, { ...options, data: items });
}
async findOneAsync(supplier, matcher, keySelector, options) {
const items = await supplier();
return this.findOne(matcher, keySelector, { ...options, data: items });
}
binarySearch(sorted, matcher, keySelector, caseInsensitive) {
let min = 0, max = sorted?.length - 1;
while (min <= max) {
const mid = Math.floor((min + max) / 2);
const midKey = keySelector(sorted[mid]);
const key = caseInsensitive && isString(midKey) ? midKey.toLowerCase() : midKey;
if (key === matcher)
return sorted[mid];
if (key < matcher)
min = mid + 1;
else
max = mid - 1;
}
return undefined;
}
fuzzySearch(array, matcher, keySelector, caseInsensitive) {
for (const item of array) {
const rawKey = keySelector(item);
const key = caseInsensitive && isString(rawKey) ? rawKey.toLowerCase() : String(rawKey);
if (this.#match(key, matcher))
return item;
}
return undefined;
}
#match(source, target) {
let i = 0;
for (const char of target) {
i = source?.indexOf(char, i);
if (i === -1)
return false;
i++;
}
return true;
}
#sortAndCache(data, getKey, cacheKey) {
if (cacheKey) {
const entry = this.#sortedCache.get(cacheKey);
if (entry && Date.now() - entry.timestamp < this.#ttl) {
return entry.result;
}
else {
this.#sortedCache.delete(cacheKey);
}
}
const sorted = [...data].sort((a, b) => {
const keyA = getKey(a);
const keyB = getKey(b);
return keyA < keyB ? -1 : keyA > keyB ? 1 : 0;
});
if (cacheKey) {
this.#sortedCache.set(cacheKey, {
result: sorted,
timestamp: Date.now(),
});
}
return sorted;
}
static #createMemoizedKeyGetter(getKey) {
const cache = new Map();
return (item) => {
if (cache.has(item))
return cache.get(item);
const key = getKey(item);
cache.set(item, key);
return key;
};
}
}