@loken/hierarchies
Version:
Library for working with hierarchies of identifiers and identifiable objects.
347 lines (281 loc) • 9.97 kB
text/typescript
import { isSomeItem, type Some, someToArray } from '@loken/utilities';
import { traverseFullGraph } from '../traversal/graph-traverse.js';
import { traverseSequence } from '../traversal/sequence-traverse.js';
import type { TraversalType } from '../traversal/graph.types.js';
import type { DeBrand, NodePredicate } from './node.types.js';
import { nodesToItems } from './node-conversion.js';
import { flattenGraph } from '../traversal/graph-flatten.js';
import { flattenSequence } from '../traversal/sequence-flatten.js';
import { searchSequence, searchSequenceMany } from '../traversal/sequence-search.js';
import { searchGraph, searchGraphMany } from '../traversal/graph-search.js';
/**
* Wrapper for an `Item` participating in a double-linked graph.
*
* By using a wrapper rather than require specific properties on the type parameter
* we don't need to make any assumptions about the wrapped type.
*/
export class HCNode<Item> {
/** Create a node wrapping the `item`. */
constructor(item: Item) {
this.#item = item;
}
//#region backing fields
#item: Item;
#parent?: HCNode<Item>;
#children?: Set<HCNode<Item>>;
/** The brand is used to lock the node to a specific owner. */
#brand?: any;
//#endregion
//#region predicates
/** A node is a "root" when there is no `parent`. */
public get isRoot() {
return this.#parent === undefined;
}
/** A node is a "leaf" when there are no `children`. */
public get isLeaf() {
return this.#children === undefined || this.#children.size === 0;
}
/** A node is "internal" when it has `children`, meaning it's either "internal" or a "leaf". */
public get isInternal() {
return !this.isLeaf;
}
/**
* A node is "linked" when it is neither a root nor a child.
*
* A node is "linked" when it has a parent or at least one child.
*/
public get isLinked() {
return !this.isRoot || !this.isLeaf;
}
/** Having a brand means that some other entity "owns" the node. */
public get isBranded(): boolean {
return this.#brand !== undefined;
}
//#endregion
//#region brands
/**
* When the brands of two nodes are compatible, they may be linked/attached
* in a parent-child relationship.
*/
public isBrandCompatible(other: HCNode<Item>): boolean {
if (this.#brand === undefined)
return other.#brand === undefined;
else if (other.#brand === undefined)
return false;
else
return this.#brand === other.#brand;
}
/**
* Adds the provided `brand` to the node,
* providing an `DeBrand` delegate for removing/clearing the brand.
*/
public brand(brand: any): DeBrand {
if (brand === undefined)
throw new Error("The brand cannot be 'undefined'.");
if (this.#brand !== undefined)
throw new Error("Must clear existing brand using the 'DeBrand' delegate before you can re-brand a node.");
this.#brand = brand;
return () => this.#brand = undefined;
}
//#endregion
//#region links
/**
* Attach the provided `children`.
*/
public attach(children: Some<HCNode<Item>>): this {
const nodes = someToArray(children);
if (nodes.length === 0)
throw new Error("Must provide one or more 'children'.");
if (!nodes.every(node => node.isRoot))
throw new Error("Must all be without a 'parent' before attaching to another.");
if (!nodes.every(node => node.isBrandCompatible(this)))
throw new Error('Must all have a compatible brand.');
this.#children ??= new Set<HCNode<Item>>();
for (const child of nodes) {
this.#children.add(child);
child.#parent = this;
}
return this;
}
/** Detach the provided `children`. */
public detach(children: Some<HCNode<Item>>): this {
const nodes = someToArray(children);
if (nodes.length === 0)
throw new Error("Must provide one or more 'children'.");
if (this.isLeaf || !nodes.every(child => this.#children!.has(child)))
throw new Error("Must all be 'children'.");
if (nodes.some(child => child.isBranded))
throw new Error("Must clear brand using the 'DeBrand' delegate before you can detach a branded node.");
for (const node of nodes) {
this.#children!.delete(node);
node.#parent = undefined;
}
if (this.isLeaf)
this.#children = undefined;
return this;
}
/** Detach the node from it's `parent`. */
public detachSelf(): this {
if (this.isRoot)
throw new Error("Can't detach a root node as there's nothing to detach it from.");
if (this.isBranded)
throw new Error("Must clear brand using the 'DeBrand' delegate before you can detach a branded node.");
if (this.#parent!.isLeaf || !this.#parent!.#children!.has(this))
throw new Error('Invalid object state: It should not be possible for the node not to be a child of its parent!.');
this.#parent!.#children!.delete(this);
if (this.#parent!.isLeaf)
this.#parent!.#children = undefined;
this.#parent = undefined;
return this;
}
/**
* Dismantling a node means to cascade detach it.
* We always cascade detach the nodes.
* We may also cascade up the ancestry, in which case the node is detached,
* and then the parent is dismantled, leading to the whole linked structure
* ending up unlinked.
* @param includeAncestry
* Should we cascade through the ancestry (true) or only cascade through the nodes (false)?
* No default value is because the caller should always make an active choice.
*/
public dismantle(includeAncestry: boolean): this {
if (!this.isRoot && includeAncestry) {
const parent = this.#parent!;
this.detachSelf();
parent.dismantle(true);
}
for (const descendant of this.getDescendants(false))
descendant.detachSelf();
return this;
}
//#endregion
//#region accessors
/** The item is the subject/content of the node. */
public get item() {
return this.#item;
}
/** Get the parent node, if any. */
public getParent() {
return this.#parent;
}
/** Get the parent item, if any. */
public getParentItem() {
return this.#parent?.item;
}
/** Get all child nodes. */
public getChildren() {
return this.#children ? [ ...this.#children ] : [];
}
/** Get all child items. */
public getChildItems() {
return this.#children ? nodesToItems(this.#children.values()) : [];
}
/** Get ancestor nodes by traversing according to the options. */
public getAncestors(includeSelf = false) {
return flattenSequence({
first: includeSelf ? this : this.#parent,
next: node => node?.getParent(),
});
}
/** Get ancestor items by traversing according to the options. */
public getAncestorItems(includeSelf = false) {
return this.getAncestors(includeSelf).map(node => node.item);
}
/** Get descendant nodes by traversing according to the options. */
public getDescendants(includeSelf = false, type: TraversalType = 'breadth-first') {
return flattenGraph({
roots: this.#getRoots(includeSelf),
next: node => node.#children,
type,
});
}
/** Get descendant items by traversing according to the options. */
public getDescendantItems(includeSelf = false, type: TraversalType = 'breadth-first') {
return this.getDescendants(includeSelf, type).map(node => node.item);
}
/** Find the first ancestor node matching the `search`. */
public findAncestor(search: NodePredicate<Item>, includeSelf = false) {
return searchSequence({
first: includeSelf ? this : this.#parent,
next: node => node.getParent(),
search,
});
}
/** Find the ancestor nodes matching the `search`. */
public findAncestors(search: NodePredicate<Item>, includeSelf = false) {
return searchSequenceMany({
first: includeSelf ? this : this.#parent,
next: node => node.getParent(),
search,
});
}
/** Find the first descendant node matching the `search`. */
public findDescendant(search: NodePredicate<Item>, includeSelf = false, type: TraversalType = 'breadth-first') {
return searchGraph({
roots: this.#getRoots(includeSelf),
next: node => node.#children,
search,
type,
});
}
/** Find the descendant nodes matching the `search`. */
public findDescendants(search: NodePredicate<Item>, includeSelf = false, type: TraversalType = 'breadth-first') {
return searchGraphMany({
roots: this.#getRoots(includeSelf),
next: node => node.#children,
search,
type,
});
}
/** Does an ancestor node matching the `search` exist? */
public hasAncestor(search: NodePredicate<Item>, includeSelf = false) {
return this.findAncestor(search, includeSelf) !== undefined;
}
/** Does a descendant node matching the `search` exist? */
public hasDescendant(search: NodePredicate<Item>, includeSelf = false, type: TraversalType = 'breadth-first') {
return this.findDescendant(search, includeSelf, type) !== undefined;
}
//#endregion
//#region traversal
/** Generate a sequence of ancestor nodes by traversing according to the options. */
public traverseAncestors(includeSelf = false) {
return traverseSequence({
first: includeSelf ? this : this.#parent,
next: node => node?.getParent(),
});
}
/** Generate a sequence of descendant nodes by traversing according to the options. */
public traverseDescendants(includeSelf = false, type: TraversalType = 'breadth-first') {
return traverseFullGraph({
roots: this.#getRoots(includeSelf),
next: node => node.#children,
type,
});
}
//#endregion
//#region helpers
/**
* Get nodes to use as roots.
*
* Note: This must be a private method as it could otherwise be exploited to modify the `#children`.
*/
#getRoots(includeSelf = false): Some<HCNode<Item>> {
return includeSelf ? this : (this.#children ?? []);
}
/**
* Get nodes to use as roots.
*/
public static getRoots<Item>(roots: Some<HCNode<Item>>, includeSelf = false): Some<HCNode<Item>> {
if (includeSelf)
return roots;
if (isSomeItem(roots))
return roots.#children ? [ ...roots.#children ] : [];
const children: HCNode<Item>[] = [];
for (const root of roots) {
if (root.#children)
children.push(...root.#children);
}
return children;
}
//#endregion
}