Skip to content

Commit

Permalink
feat(associative): add BidirIndex & tests
Browse files Browse the repository at this point in the history
  • Loading branch information
postspectacular committed Jul 17, 2022
1 parent 6a76c75 commit 26f749f
Show file tree
Hide file tree
Showing 4 changed files with 332 additions and 0 deletions.
3 changes: 3 additions & 0 deletions packages/associative/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@
"./array-set": {
"default": "./array-set.js"
},
"./bidir-index": {
"default": "./bidir-index.js"
},
"./checks": {
"default": "./checks.js"
},
Expand Down
280 changes: 280 additions & 0 deletions packages/associative/src/bidir-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import { isString } from "@thi.ng/checks/is-string";

export interface SerializedBidirIndex<T> {
pairs: [T, number][];
nextID: number;
}

export interface BidirIndexOpts<T> {
/**
* Custom `key -> id` map implementation (e.g. {@link EquivMap} or
* {@link HashMap}). If omitted, a native JS `Map` will be used.
*/
map: Map<T, number>;
/**
* Start ID for indexing new keys.
*
* @defaultValue 0
*/
start: number;
}

/**
* Bi-directional index to map arbitrary keys to numeric IDs and vice versa.
*/
export class BidirIndex<T> {
fwd: Map<T, number>;
rev: Map<number, T>;
nextID: number;

constructor(
keys?: Iterable<T> | null,
opts: Partial<BidirIndexOpts<T>> = {}
) {
this.nextID = opts.start || 0;
this.fwd = opts.map || new Map();
this.rev = new Map();
keys && this.addAll(keys);
}

get size() {
return this.fwd.size;
}

/**
* Yields same result as {@link BidirIndex.entries}.
*/
[Symbol.iterator]() {
return this.entries();
}

/**
* Returns iterator of `[key,id]` pairs.
*/
entries() {
return this.fwd.entries();
}

/**
* Returns iterator of all indexed keys.
*/
keys() {
return this.fwd.keys();
}

/**
* Returns iterator of all indexed IDs.
*/
values() {
return this.fwd.values();
}

/**
* Returns true if given `key` is known/indexed.
*
* @param key
*/
has(key: T) {
return this.fwd.has(key);
}

/**
* Returns true if given `id` has a corresponding known/indexed key.
*
* @param id
*/
hasID(id: number) {
return this.rev.has(id);
}

/**
* Reverse lookup of {@link BidirIndex.getID}. Returns the matching ID for
* given `key` or undefined if the key is not known.
*
* @param key
*/
get(key: T) {
return this.fwd.get(key);
}

/**
* Reverse lookup of {@link BidirIndex.get}. Returns the matching key for
* given `id` or undefined if the ID is not known.
*
* @param id
*/
getID(id: number) {
return this.rev.get(id);
}

/**
* Indexes given `key` and assigns & returns a new ID. If `key` is already
* known/indexed, returns its existing ID.
*
* @param key
*/
add(key: T) {
let id = this.fwd.get(key);
if (id === undefined) {
this.fwd.set(key, this.nextID);
this.rev.set(this.nextID, key);
id = this.nextID++;
}
return id;
}

/**
* Batch version of {@link BidirIndex.add}. Indexes all given keys and
* returns array of their corresponding IDs.
*
* @param keys
*/
addAll(keys: Iterable<T>) {
const res: number[] = [];
for (let k of keys) {
res.push(this.add(k));
}
return res;
}

/**
* Removes bi-directional mapping for given `key` from the index. Returns
* true if successful.
*
* @param key
*/
delete(key: T) {
const fwd = this.fwd;
const id = fwd.get(key);
if (id !== undefined) {
fwd.delete(key);
this.rev.delete(id);
return true;
}
return false;
}

/**
* Removes bi-directional mapping for given `id` from the index. Returns
* true if successful.
*
* @param id
*/
deleteID(id: number) {
const rev = this.rev;
const k = rev.get(id);
if (k !== undefined) {
rev.delete(id);
this.fwd.delete(k);
return true;
}
return false;
}

/**
* Batch version of {@link BidirIndex.delete}.
*
* @param keys
*/
deleteAll(keys: Iterable<T>) {
for (let k of keys) this.delete(k);
}

/**
* Batch version of {@link BidirIndex.deleteID}.
*
* @param ids
*/
deleteAllIDs(ids: Iterable<number>) {
for (let id of ids) this.deleteID(id);
}

/**
* Returns array of IDs for all given keys. If `fail` is true (default:
* false), throws error if any of the given keys is unknown/unindexed (use
* {@link BidirIndex.add} or {@link BidirIndex.addAll} first).
*
* @param keys
* @param fail
*/
getAll(keys: Iterable<T>, fail = false) {
const index = this.fwd;
const res: number[] = [];
for (let k of keys) {
const id = index.get(k);
if (id === undefined) {
if (fail) throw new Error(`unknown key: ${k}`);
} else {
res.push(id);
}
}
return res;
}

/**
* Returns array of matching keys for all given IDs. If `fail` is true
* (default: false), throws error if any of the given IDs is
* unknown/unindexed (use {@link BidirIndex.add} or
* {@link BidirIndex.addAll} first).
*
* @param ids
* @param fail
*/
getAllIDs(ids: Iterable<number>, fail = false) {
const index = this.rev;
const res: T[] = [];
for (let id of ids) {
const k = index.get(id);
if (k === undefined) {
if (fail) throw new Error(`unknwon ID: ${id}`);
} else {
res.push(k);
}
}
return res;
}

/**
* Returns a compact JSON serializable version of the index. Use
* {@link bidirIndexFromJSON} to instantiate an index from such a JSON
* serialization.
*/
toJSON(): SerializedBidirIndex<T> {
return {
pairs: [...this.entries()],
nextID: this.nextID,
};
}
}

/**
* Factory function wrapper for {@link BidirIndex}.
*
* @param keys
* @param opts
*/
export const defBidirIndex = <T>(
keys?: Iterable<T>,
opts?: Partial<BidirIndexOpts<T>>
) => new BidirIndex<T>(keys, opts);

/**
* Instantiates a {@link BidirIndex} from given JSON serialization. The optional
* `map` arg can be used to provide a customized `key -> id` map implementation
* (same use as {@link BidirIndexOpts.map}).
*
* @param src
* @param map
*/
export const bidirIndexFromJSON = <T>(
src: string | SerializedBidirIndex<T>,
map?: Map<T, number>
) => {
const $src = isString(src) ? <SerializedBidirIndex<T>>JSON.parse(src) : src;
const res = new BidirIndex(null, { map, start: $src.nextID });
$src.pairs.forEach(([k, id]) => {
res.fwd.set(k, id);
res.rev.set(id, k);
});
return res;
};
1 change: 1 addition & 0 deletions packages/associative/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./api.js";
export * from "./array-set.js";
export * from "./bidir-index.js";
export * from "./checks.js";
export * from "./common-keys.js";
export * from "./copy.js";
Expand Down
48 changes: 48 additions & 0 deletions packages/associative/test/bidir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { group } from "@thi.ng/testament";
import * as assert from "assert";
import { defBidirIndex } from "../src/index.js";

group("BidirIndex", {
addAll: () => {
const idx = defBidirIndex("abc", { start: 100 });
assert.deepStrictEqual(
idx.fwd,
new Map([
["a", 100],
["b", 101],
["c", 102],
])
);
assert.deepStrictEqual(
idx.rev,
new Map([
[100, "a"],
[101, "b"],
[102, "c"],
])
);
},
getAll: () => {
const idx = defBidirIndex("abc");
assert.deepStrictEqual(idx.getAll("cba"), [2, 1, 0]);
assert.deepStrictEqual(idx.getAllIDs([2, 1, 0]), ["c", "b", "a"]);
},
deleteAll: () => {
const idx = defBidirIndex("abcd", { start: 100 });
idx.deleteAll("bd");
assert.deepStrictEqual(
idx.fwd,
new Map([
["a", 100],
["c", 102],
])
);
assert.deepStrictEqual(
idx.rev,
new Map([
[100, "a"],
[102, "c"],
])
);
},
});

0 comments on commit 26f749f

Please sign in to comment.