UNPKG

@loken/hierarchies

Version:

Library for working with hierarchies of identifiers and identifiable objects.

347 lines (281 loc) 9.97 kB
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 }