@naturalcycles/js-lib
Version:
Standard library for universal (browser + Node.js) javascript
222 lines (187 loc) • 5.33 kB
text/typescript
import { _assert } from '../error/index.js'
import type { Comparator } from '../types.js'
export interface LazyKeySortedMapOptions<K> {
/**
* Defaults to undefined.
* Undefined (default comparator) works well for String keys.
* For Number keys - use comparators.numericAsc (or desc),
* otherwise sorting will be wrong (lexicographic).
*/
comparator?: Comparator<K>
}
/**
* Maintains sorted array of keys.
* Sorts **data access**, not on insertion.
*
* @experimental
*/
export class LazyKeySortedMap<K, V> implements Map<K, V> {
private readonly map: Map<K, V>
private readonly maybeSortedKeys: K[]
private keysAreSorted = false
constructor(entries: [K, V][] = [], opt: LazyKeySortedMapOptions<K> = {}) {
this.#comparator = opt.comparator
this.map = new Map(entries)
this.maybeSortedKeys = [...this.map.keys()]
}
readonly #comparator: Comparator<K> | undefined
/**
* Convenience way to create KeySortedMap from object.
*/
static of<V>(obj: Record<any, V>): LazyKeySortedMap<string, V> {
return new LazyKeySortedMap(Object.entries(obj))
}
get size(): number {
return this.map.size
}
clear(): void {
this.map.clear()
this.maybeSortedKeys.length = 0
this.keysAreSorted = true
}
has(key: K): boolean {
return this.map.has(key)
}
get(key: K): V | undefined {
return this.map.get(key)
}
/**
* Allows to set multiple key-value pairs at once.
*/
setMany(obj: Record<any, V>): this {
for (const [k, v] of Object.entries(obj)) {
this.map.set(k as K, v)
this.maybeSortedKeys.push(k as K)
}
this.keysAreSorted = false
return this
}
/**
* Insert or update. Keeps keys array sorted at all times.
* Returns this (Map-like).
*/
set(key: K, value: V): this {
if (!this.map.has(key)) {
this.maybeSortedKeys.push(key)
this.keysAreSorted = false
}
this.map.set(key, value)
return this
}
/**
* Delete by key. Returns boolean like Map.delete.
*/
delete(key: K): boolean {
if (!this.map.has(key)) return false
this.map.delete(key)
// Delete operation keeps the array **as-is**, it may have been sorted or not.
const j = this.maybeSortedKeys.indexOf(key)
if (j !== -1) this.maybeSortedKeys.splice(j, 1)
return true
}
/**
* Iterables (Map-compatible), all in sorted order.
*/
*keys(): MapIterator<K> {
for (const key of this.getSortedKeys()) {
yield key
}
}
*values(): MapIterator<V> {
for (const key of this.getSortedKeys()) {
yield this.map.get(key)!
}
}
*entries(): MapIterator<[K, V]> {
for (const k of this.getSortedKeys()) {
yield [k, this.map.get(k)!]
}
}
[Symbol.iterator](): MapIterator<[K, V]> {
return this.entries()
}
[Symbol.toStringTag] = 'KeySortedMap'
/**
* Zero-allocation callbacks over sorted data (faster than spreading to arrays).
*/
forEach(cb: (value: V, key: K, map: Map<K, V>) => void, thisArg?: any): void {
const { map } = this
for (const k of this.getSortedKeys()) {
cb.call(thisArg, map.get(k)!, k, this)
}
}
firstKeyOrUndefined(): K | undefined {
return this.getSortedKeys()[0]
}
firstKey(): K {
_assert(this.maybeSortedKeys.length, 'Map.firstKey called on empty map')
return this.getSortedKeys()[0]!
}
lastKeyOrUndefined(): K | undefined {
if (!this.maybeSortedKeys.length) return
const keys = this.getSortedKeys()
return keys[keys.length - 1]
}
lastKey(): K {
const k = this.lastKeyOrUndefined()
_assert(k, 'Map.lastKey called on empty map')
return k
}
firstValueOrUndefined(): V | undefined {
if (!this.maybeSortedKeys.length) return
return this.map.get(this.getSortedKeys()[0]!)
}
firstValue(): V {
const v = this.firstValueOrUndefined()
_assert(v, 'Map.firstValue called on empty map')
return v
}
lastValueOrUndefined(): V | undefined {
if (!this.maybeSortedKeys.length) return
const keys = this.getSortedKeys()
return this.map.get(keys[keys.length - 1]!)
}
lastValue(): V {
const v = this.lastValueOrUndefined()
_assert(v, 'Map.lastValue called on empty map')
return v
}
firstEntryOrUndefined(): [K, V] | undefined {
if (!this.maybeSortedKeys.length) return
const k = this.getSortedKeys()[0]!
return [k, this.map.get(k)!]
}
firstEntry(): [K, V] {
const e = this.firstEntryOrUndefined()
_assert(e, 'Map.firstEntry called on empty map')
return e
}
lastEntryOrUndefined(): [K, V] | undefined {
if (!this.maybeSortedKeys.length) return
const keys = this.getSortedKeys()
const k = keys[keys.length - 1]!
return [k, this.map.get(k)!]
}
lastEntry(): [K, V] {
const e = this.firstEntryOrUndefined()
_assert(e, 'Map.lastEntry called on empty map')
return e
}
toJSON(): Record<string, V> {
return this.toObject()
}
toObject(): Record<string, V> {
return Object.fromEntries(this.entries())
}
private getSortedKeys(): K[] {
if (!this.keysAreSorted) {
return this.sortKeys()
}
return this.maybeSortedKeys
}
private sortKeys(): K[] {
this.maybeSortedKeys.sort(this.#comparator)
this.keysAreSorted = true
return this.maybeSortedKeys
}
}