@loken/hierarchies
Version:
Library for working with hierarchies of identifiers and identifiable objects.
597 lines (485 loc) • 17.7 kB
text/typescript
import { type MapArgs, mapArgs, MultiMap, type Some, someToArray, someToIterable } from '@loken/utilities';
import type { HCNode } from '../nodes/node.js';
import type { DeBrand, NodePredicate } from '../nodes/node.types.js';
import { nodesToIds, nodesToItems } from '../nodes/node-conversion.js';
import { Nodes } from '../nodes/nodes.js';
import { ChildMap } from '../utilities/child-map.js';
import type { Identify } from '../utilities/identify.js';
import type { Relation } from '../utilities/relations.js';
import { Hierarchies } from './hierarchies.js';
/** Contains the `id`, `item` and `node` for a `HCNode` in a `Hierarchy`. */
export type HierarchyEntry<Item, Id> = [id: Id, item: Item, node: HCNode<Item>];
/**
* Keeps track of the a set of nodes and their roots
* and provides structural modification.
*/
export class Hierarchy<Item, Id = Item> {
/** Create a hierarchy using the provided `identify` function. */
constructor(identify: Identify<Item, Id>) {
this.#identify = identify;
}
//#region backing fields
#roots = new Map<Id, HCNode<Item>>();
#nodes = new Map<Id, HCNode<Item>>();
#debrand = new Map<Id, DeBrand>();
#identify: Identify<Item, Id>;
//#endregion
//#region accessors
/** Get a shallow clone of the roots. */
public get roots(): HCNode<Item>[] {
return [ ...this.#roots.values() ];
}
/** Get a shallow clone of the root items. */
public get rootItems(): Item[] {
return this.roots.map(r => r.item);
}
/** Get a shallow clone of the root IDs. */
public get rootIds(): Id[] {
return this.roots.map(r => this.#identify(r.item));
}
/** Get a shallow clone of all nodes. */
public get nodes(): HCNode<Item>[] {
return [ ...this.#nodes.values() ];
}
/** Get a shallow clone of all node items. */
public get nodeItems(): Item[] {
return this.nodes.map(n => n.item);
}
/** Get a shallow clone of all node IDs. */
public get nodeIds(): Id[] {
return this.nodes.map(n => this.#identify(n.item));
}
/** Means of getting an ID for an `item`. */
public get identify(): Identify<Item, Id> {
return this.#identify;
}
/** Map the `ids` to a boolean signifying their presence. */
public has<Ids extends Id[]>(...ids: Ids): MapArgs<Ids, boolean, true, false> {
return mapArgs(ids, id => this.#nodes.has(id), true, false);
}
/** Is every `Id` in the `ids` present? */
public hasEvery(ids: Some<Id>): boolean {
for (const id of someToIterable(ids)) {
if (!this.#nodes.has(id))
return false;
}
return true;
}
/** Is some `Id` in the `ids` present? */
public hasSome(ids: Some<Id>): boolean {
for (const id of someToIterable(ids)) {
if (this.#nodes.has(id))
return true;
}
return false;
}
/**
* Get node or nodes by their `Id`.
* @param ids The IDs of nodes to retrieve.
* @returns A single node when you pass a single ID and a fixed length tuple of nodes when you pass multiple IDs.
* @throws The 'id' must be a hierarchy member.
* @throws Must provide at least one argument.
*/
public get<Ids extends Id[]>(...ids: Ids): MapArgs<Ids, HCNode<Item>, true, false> {
return mapArgs(ids, id => this.#get(id), true, false);
}
/**
* Get nodes by their `Id`s.
* @param ids The IDs of nodes to retrieve.
* @returns An array of nodes.
*/
public getSome(ids: Some<Id>): HCNode<Item>[] {
const nodes: HCNode<Item>[] = [];
for (const id of someToIterable(ids)) {
const node = this.#nodes.get(id);
if (node)
nodes.push(node);
}
return nodes;
}
/**
* Get item or items by `Id`.
* @param ids The IDs of items to retrieve.
* @throws The 'id' must be a hierarchy member.
* @throws Must provide at least one argument.
* @returns A single item when you pass a single ID and a fixed length tuple of items when you pass multiple IDs.
*/
public getItems<Ids extends Id[]>(...ids: Ids): MapArgs<Ids, Item, true, false> {
return mapArgs(ids, id => this.#get(id).item, true, false);
}
/**
* Get item or items by `Id`.
* @param ids The IDs of items to retrieve.
* @returns An array of items.
*/
public getSomeItems(ids: Some<Id>): Item[] {
return this.getSome(ids).map(node => node.item);
}
#get(id: Id) {
const node = this.#nodes.get(id);
if (node === undefined)
throw new Error("The 'id' must be a hierarchy member.");
return node;
}
//#endregion
//#region links
/**
* Attach the provided `roots`.
* @param roots Nodes to attach.
*/
public attachRoot(roots: Some<HCNode<Item>>): this {
const nodes = someToArray(roots);
if (!nodes.every(n => n.isRoot))
throw new Error("The 'roots' all be roots!");
this.#addNodes(nodes, true);
return this;
}
/**
* Attach the provided `children` to the node of the provided `parentId`.
* @param parentId The ID of the node to attach to.
* @param children Nodes to attach.
*/
public attach(parentId: Id, children: Some<HCNode<Item>>): this {
if (!this.#nodes.has(parentId))
throw new Error("The 'parentId' must be a hierarchy member.");
this.#addNodes(children);
const parent = this.#nodes.get(parentId)!;
parent.attach(children);
return this;
}
/**
* Detach the provided `nodes`.
* @param nodes Nodes to detach.
*/
public detach(nodes: Some<HCNode<Item>>): this {
for (const node of Nodes.getDescendants(nodes, true)) {
const id = this.#identify(node.item);
this.#debrand.get(id)!();
this.#debrand.delete(id);
this.#nodes.delete(id);
this.#roots.delete(id);
}
for (const node of someToIterable(nodes)) {
if (!node.isRoot)
node.detachSelf();
}
return this;
}
#addNodes(nodes: Some<HCNode<Item>>, asRoot = false) {
for (const node of Nodes.getDescendants(nodes, true)) {
const id = this.#identify(node.item);
this.#debrand.set(id, node.brand(this));
this.#nodes.set(id, node);
}
if (!asRoot)
return;
for (const node of someToIterable(nodes))
this.#roots.set(this.#identify(node.item), node);
}
//#endregion
//#region traversal
/**
* Get the chain of ancestor nodes starting with the node for the item matching the `id`.
*/
public getAncestors(ids: Some<Id>, includeSelf = false): HCNode<Item>[] {
const nodes = this.getSome(ids);
return nodes ? Nodes.getAncestors(nodes, includeSelf) : [];
}
/**
* Get the items from the chain of ancestor nodes starting with the node for the item matching the `id`.
*/
public getAncestorItems(ids: Some<Id>, includeSelf = false): Item[] {
return nodesToItems(this.getAncestors(ids, includeSelf));
}
/**
* Get the IDs from the chain of ancestor nodes starting with the node for the item matching the `id`.
*/
public getAncestorIds(ids: Some<Id>, includeSelf = false): Id[] {
return nodesToIds(this.getAncestors(ids, includeSelf), this.#identify);
}
/**
* Get the entries from the chain of ancestor nodes starting with the node for the item matching the `id`.
*/
public getAncestorEntries(id: Id, includeSelf = false): HierarchyEntry<Item, Id>[] {
return this.getAncestors(id, includeSelf).map(node => {
return [ this.#identify(node.item), node.item, node ];
});
}
/**
* Get the chain of descendant nodes starting with the nodes for the items matching the `ids`.
*/
public getDescendants(ids: Some<Id>, includeSelf = false): HCNode<Item>[] {
const roots = this.getSome(ids);
if (roots.length === 0)
return [];
return Nodes.getDescendants(roots, includeSelf);
}
/**
* Get the items from the chain of descendant nodes starting with the nodes for the items matching the `ids`.
*/
public getDescendantItems(ids: Some<Id>, includeSelf = false): Item[] {
return nodesToItems(this.getDescendants(ids, includeSelf));
}
/**
* Get the IDs from the chain of descendant nodes starting with the nodes for the items matching the `ids`.
*/
public getDescendantIds(ids: Some<Id>, includeSelf = false): Id[] {
return nodesToIds(this.getDescendants(ids, includeSelf), this.#identify);
}
/**
* Get the entries from the chain of descendant nodes starting with the nodes for the items matching the `ids`.
*/
public getDescendantEntries(ids: Some<Id>, includeSelf = false): HierarchyEntry<Item, Id>[] {
return this.getDescendants(ids, includeSelf).map(node => {
return [ this.#identify(node.item), node.item, node ];
});
}
/**
* Get the chain of descendant nodes starting with the hierarchy `roots`.
*/
public getAllDescendants(includeSelf = false): HCNode<Item>[] {
return Nodes.getDescendants(this.roots, includeSelf);
}
/**
* Get the items from the chain of descendant nodes starting with the hierarchy `roots`.
*/
public getAllDescendantItems(includeSelf = false): Item[] {
return nodesToItems(Nodes.getDescendants(this.roots, includeSelf));
}
/**
* Get the IDs from the chain of descendant nodes starting with the hierarchy `roots`.
*/
public getAllDescendantIds(includeSelf = false): Id[] {
return nodesToIds(Nodes.getDescendants(this.roots, includeSelf), this.#identify);
}
/**
* Get the entries from the chain of descendant nodes starting with the hierarchy `roots`.
*/
public getAllDescendantEntries(includeSelf = false): HierarchyEntry<Item, Id>[] {
return Nodes.getDescendants(this.roots, includeSelf).map(node => {
return [ this.#identify(node.item), node.item, node ];
});
}
//#endregion
//#region search
/**
* Find nodes matching a list of `Id`s or a `HCNode<Item>` predicate.
*/
public find(search: Some<Id> | NodePredicate<Item>): HCNode<Item>[] {
if (typeof search === 'function') {
const result: HCNode<Item>[] = [];
for (const node of this.#nodes.values()) {
if ((search as NodePredicate<Item>)(node))
result.push(node);
}
return result;
}
return this.getSome(search);
}
/**
* Find nodes matching a list of `Id`s or a `HCNode<Item>` predicate.
*/
public findItems(search: Some<Id> | NodePredicate<Item>): Item[] {
return this.find(search).map(node => node.item);
}
/**
* Find `Id`s matching a list of `Id`s or a `HCNode<Item>` predicate.
*/
public findIds(search: Some<Id> | NodePredicate<Item>): Id[] {
const result: Id[] = [];
if (typeof search === 'function') {
for (const [ id, node ] of this.#nodes) {
if ((search as NodePredicate<Item>)(node))
result.push(id);
}
}
else {
for (const id of someToIterable(search)) {
if (this.#nodes.has(id))
result.push(id);
}
}
return result;
}
/**
* Find entries matching a list of `Id`s or a `HCNode<Item>` predicate.
*/
public findEntries(search: Some<Id> | NodePredicate<Item>): HierarchyEntry<Item, Id>[] {
const result: HierarchyEntry<Item, Id>[] = [];
if (typeof search === 'function') {
for (const node of this.#nodes.values()) {
if ((search as NodePredicate<Item>)(node))
result.push([ this.#identify(node.item), node.item, node ]);
}
}
else {
for (const id of someToIterable(search)) {
const node = this.#nodes.get(id);
if (node)
result.push([ id, node.item, node ]);
}
}
return result;
}
/** Find the common ancestor node which is the closest to the `ids`. */
public findCommonAncestor(ids: Some<Id>, includeSelf = false): HCNode<Item> | undefined {
const nodes = this.getSome(ids);
return Nodes.findCommonAncestor(nodes, includeSelf);
}
/** Find the ancestor nodes common to the `ids`. */
public findCommonAncestors(ids: Some<Id>, includeSelf = false): HCNode<Item>[] | undefined {
const nodes = this.getSome(ids);
return Nodes.findCommonAncestors(nodes, includeSelf);
}
/**
* Find a node matching the `search` which is an ancestor of a node with one of the `ids`.
*/
public findAncestor(ids: Some<Id>, search: Some<Id> | NodePredicate<Item>, includeSelf = false): HCNode<Item> | undefined {
const roots = this.getSome(ids);
return Nodes.findAncestor(roots, this.normalizeSearch(search), includeSelf);
}
/**
* Find a node matching the `search` which is an ancestor of a node with one of the `ids`.
*/
public findAncestors(ids: Some<Id>, search: Some<Id> | NodePredicate<Item>, includeSelf = false): HCNode<Item>[] {
const roots = this.getSome(ids);
return Nodes.findAncestors(roots, this.normalizeSearch(search), includeSelf);
}
/**
* Find a node matching the `search` which is an descendant of a node with one of the `ids`.
*/
public findDescendant(ids: Some<Id>, search: Some<Id> | NodePredicate<Item>, includeSelf = false): HCNode<Item> | undefined {
const roots = this.getSome(ids);
return Nodes.findDescendant(roots, this.normalizeSearch(search), includeSelf);
}
/**
* Find nodes matching the `search` which are descendants of a node with one of the `ids`.
*/
public findDescendants(ids: Some<Id>, search: Some<Id> | NodePredicate<Item>, includeSelf = false): HCNode<Item>[] {
const roots = this.getSome(ids);
return Nodes.findDescendants(roots, this.normalizeSearch(search), includeSelf);
}
/**
* Does a node with one of the `ids` have an ancestor node matching the `search`?
*/
public hasAncestor(ids: Some<Id>, search: Some<Id> | NodePredicate<Item>, includeSelf = false): boolean {
const roots = this.getSome(ids);
return Nodes.hasAncestor(roots, this.normalizeSearch(search), includeSelf);
}
/**
* Does a node with one of the `ids` have a descendant node matching the `search`?
*/
public hasDescendant(ids: Some<Id>, search: Some<Id> | NodePredicate<Item>, includeSelf = false): boolean {
const roots = this.getSome(ids);
return Nodes.hasDescendant(roots, this.normalizeSearch(search), includeSelf);
}
/**
* Create a new `Hierarchy` from matching items.
*
* @param search A list of `Id`s or a `HCNode<Item>` predicate.
* @param include Optional facets to include: The nodes that are `matches`, their `ancestors` and/or their `descendants` in the result.
* - When not specified you are getting all three facets as a default.
* - When the options object is specified you must opt in to the facets you want and must enable at least one.
* @returns A new `Hierarchy<Item, Id>` with new nodes wrapping the same `Item`s as in the searched hierarchy pruned to fit the search and `include` facets.
* @throws If you provide `include` options but enable no facets.
*/
public search(
search: Some<Id> | NodePredicate<Item>,
include?: { matches?: boolean, ancestors?: boolean, descendants?: boolean },
): Hierarchy<Item, Id> {
include ??= {
matches: true,
ancestors: true,
descendants: true,
};
if (!(include.matches || include.ancestors || include.descendants))
throw new Error("Must enable at least one facet to 'include'.");
const childMap = new MultiMap<Id>();
const items = new Map<Id, Item>();
for (const [ id, item, node ] of this.findEntries(search)) {
if (include.ancestors) {
const ancestorIds: Id[] = [];
for (const [ ancestorId, ancestorItem ] of this.getAncestorEntries(id, include.matches)) {
ancestorIds.push(ancestorId);
if (!items.has(ancestorId))
items.set(ancestorId, ancestorItem);
}
ChildMap.addAncestors(ancestorIds, childMap);
}
if (include.descendants) {
for (const [ descendantId, descendantItem, descendantNode ] of this.getDescendantEntries(id, include.matches)) {
if (!items.has(descendantId)) {
items.set(descendantId, descendantItem);
if (descendantNode.isLeaf)
childMap.getOrAdd(descendantId);
}
if (!descendantNode.isLeaf) {
for (const childNode of descendantNode.getChildren()) {
const childItem = childNode.item;
const childId = this.#identify(childItem);
childMap.add(descendantId, childId);
items.set(childId, childItem);
}
}
}
}
if (include.matches && !include.ancestors && !include.descendants) {
let addedToChildMap = false;
if (!node.isRoot) {
const parentId = this.#identify(node.getParentItem()!);
if (items.has(parentId)) {
childMap.add(parentId, id);
addedToChildMap = true;
}
}
if (!node.isLeaf) {
const includedChildIds = node
.getChildItems()
.map(this.#identify)
.filter(id => items.has(id));
if (includedChildIds.length) {
childMap.add(id, includedChildIds);
addedToChildMap = true;
}
}
if (!addedToChildMap)
childMap.getOrAdd(id);
items.set(id, item);
}
}
return Hierarchies.createWithItems({
items: [ ...items.values() ],
identify: this.#identify,
spec: childMap,
});
}
protected normalizeSearch(search: Some<Id> | NodePredicate<Item>): NodePredicate<Item> {
if (typeof search === 'function')
return search as NodePredicate<Item>;
if (Array.isArray(search))
return (node) => search.includes(this.#identify(node.item));
else if (search instanceof Set)
return (node) => search.has(this.#identify(node.item));
else
return (node) => this.#identify(node.item) === search;
}
//#endregion
//#region MultiMaps
/** Create a map of ids to child-ids by traversing the `hierarchy`. */
public toChildMap(): MultiMap<Id> {
return Nodes.toChildMap(this.roots, this.#identify);
}
/** Create a map of ids to descendant-ids by traversing the `hierarchy`. */
public toDescendantMap(): MultiMap<Id> {
return Nodes.toDescendantMap(this.roots, this.#identify);
}
/** Create a map of ids to ancestor-ids by traversing the `hierarchy`. */
public toAncestorMap(): MultiMap<Id> {
return Nodes.toAncestorMap(this.roots, this.#identify);
}
/** Create a list of relations by traversing the graph of the `hierarchy`. */
public toRelations(): Relation<Id>[] {
return Nodes.toRelations(this.roots, this.#identify);
}
//#endregion
}