@itwin/presentation-hierarchies
Version:
A package for creating hierarchies based on data in iTwin.js iModels.
446 lines • 24.6 kB
JavaScript
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
import "../internal/DisposePolyfill.js";
import { catchError, concat, defaultIfEmpty, EMPTY, filter, finalize, from, identity, map, merge, mergeAll, mergeMap, of, Subject, take, takeUntil, tap, } from "rxjs";
import { assert, BeEvent, Guid, StopWatch } from "@itwin/core-bentley";
import { createDefaultValueFormatter, formatConcatenatedValue, normalizeFullClassName, } from "@itwin/presentation-shared";
import { RowsLimitExceededError } from "../HierarchyErrors.js";
import { HierarchyNode } from "../HierarchyNode.js";
import { HierarchyNodeKey } from "../HierarchyNodeKey.js";
import { LOGGING_NAMESPACE as BASE_LOGGING_NAMESPACE, LOGGING_NAMESPACE_INTERNAL as BASE_LOGGING_NAMESPACE_INTERNAL, LOGGING_NAMESPACE_PERFORMANCE as BASE_LOGGING_NAMESPACE_PERFORMANCE, createNodeIdentifierForLogging, hasChildren, } from "../internal/Common.js";
import { eachValueFrom } from "../internal/EachValueFrom.js";
import { doLog, log } from "../internal/LoggingUtils.js";
import { partition } from "../internal/operators/Partition.js";
import { reduceToMergeMapList } from "../internal/operators/ReduceToMergeMap.js";
import { shareReplayWithErrors } from "../internal/operators/ShareReplayWithErrors.js";
import { sortNodesByLabelOperator } from "../internal/operators/Sorting.js";
import { getRxjsHierarchyDefinition } from "../internal/RxjsHierarchyDefinition.js";
import { SubscriptionScheduler } from "../internal/SubscriptionScheduler.js";
import { FilteringHierarchyDefinition } from "./FilteringHierarchyDefinition.js";
import { HierarchyCache } from "./HierarchyCache.js";
import { HierarchyNodesDefinition } from "./IModelHierarchyDefinition.js";
import { NodeSelectClauseColumnNames } from "./NodeSelectQueryFactory.js";
import { createDetermineChildrenOperator } from "./operators/DetermineChildren.js";
import { createGroupingOperator } from "./operators/Grouping.js";
import { createHideIfNoChildrenOperator } from "./operators/HideIfNoChildren.js";
import { createHideNodesInHierarchyOperator } from "./operators/HideNodesInHierarchy.js";
import { readNodes } from "./TreeNodesReader.js";
const LOGGING_NAMESPACE = `${BASE_LOGGING_NAMESPACE}.IModelHierarchyProvider`;
const LOGGING_NAMESPACE_INTERNAL = `${BASE_LOGGING_NAMESPACE_INTERNAL}.IModelHierarchyProvider`;
const LOGGING_NAMESPACE_PERFORMANCE = `${BASE_LOGGING_NAMESPACE_PERFORMANCE}.IModelHierarchyProvider`;
const DEFAULT_QUERY_CONCURRENCY = 10;
const DEFAULT_QUERY_CACHE_SIZE = 1;
/**
* Creates an instance of `HierarchyProvider` that creates a hierarchy based on given iModel and
* a hierarchy definition, which defines each hierarchy level through ECSQL queries.
*
* @public
*/
export function createIModelHierarchyProvider(props) {
return new IModelHierarchyProviderImpl(props);
}
class IModelHierarchyProviderImpl {
_imodelAccess;
_hierarchyChanged;
_valuesFormatter;
_sourceHierarchyDefinition;
_activeHierarchyDefinition;
_localizedStrings;
_queryScheduler;
_nodesCache;
_unsubscribe;
_dispose = new Subject();
constructor(props) {
this._imodelAccess = props.imodelAccess;
this._hierarchyChanged = new BeEvent();
this._activeHierarchyDefinition = this._sourceHierarchyDefinition = getRxjsHierarchyDefinition(props.hierarchyDefinition);
this._valuesFormatter = props?.formatter ?? createDefaultValueFormatter();
this._localizedStrings = { other: "Other", unspecified: "Not specified", ...props?.localizedStrings };
this._queryScheduler = new SubscriptionScheduler(props.queryConcurrency ?? DEFAULT_QUERY_CONCURRENCY);
this.setHierarchyFilter(props.filtering);
const queryCacheSize = props.queryCacheSize ?? DEFAULT_QUERY_CACHE_SIZE;
if (queryCacheSize !== 0) {
this._nodesCache = new HierarchyCache({
// we divide the size by 2, because each variation also counts as a query that we cache
size: Math.ceil(queryCacheSize / 2),
variationsCount: 1,
});
}
this._unsubscribe = props.imodelChanged?.addListener(() => {
this.invalidateHierarchyCache("Data source changed");
this._dispose.next();
this._hierarchyChanged.raiseEvent();
});
}
[Symbol.dispose]() {
this._dispose.next();
this._unsubscribe?.();
}
/* c8 ignore next 3 */
dispose() {
this[Symbol.dispose]();
}
get hierarchyChanged() {
return this._hierarchyChanged;
}
invalidateHierarchyCache(reason) {
doLog({
category: `${LOGGING_NAMESPACE}.Events`,
message: /* c8 ignore next */ () => (reason ? `${reason}: clear hierarchy cache` : `Clear hierarchy cache`),
});
this._nodesCache?.clear();
}
/**
* Sets `HierarchyProvider` values formatter that formats nodes' labels. If provided `undefined`, then defaults to the
* result of `createDefaultValueFormatter` called with default parameters.
*/
setFormatter(formatter) {
this._valuesFormatter = formatter ?? createDefaultValueFormatter();
this._hierarchyChanged.raiseEvent({ formatterChange: { newFormatter: this._valuesFormatter } });
}
setHierarchyFilter(props) {
if (!props) {
if (this._sourceHierarchyDefinition !== this._activeHierarchyDefinition) {
this._activeHierarchyDefinition = this._sourceHierarchyDefinition;
this.invalidateHierarchyCache("Hierarchy filter reset");
}
this._hierarchyChanged.raiseEvent({ filterChange: { newFilter: undefined } });
return;
}
this._activeHierarchyDefinition = new FilteringHierarchyDefinition({
imodelAccess: this._imodelAccess,
source: this._sourceHierarchyDefinition,
nodeIdentifierPaths: props.paths,
});
this.invalidateHierarchyCache("Hierarchy filter set");
this._dispose.next();
this._hierarchyChanged.raiseEvent({ filterChange: { newFilter: props } });
}
onGroupingNodeCreated(groupingNode, props) {
this._nodesCache?.set({ ...props, parentNode: groupingNode }, { observable: from(groupingNode.children), processingStatus: "pre-processed" });
}
createHierarchyLevelDefinitionsObservable(props) {
const { requestContext, ...defineHierarchyLevelProps } = props;
doLog({
category: LOGGING_NAMESPACE_PERFORMANCE,
message: /* c8 ignore next */ () => `[${requestContext.requestId}] Requesting hierarchy level definitions for ${createNodeIdentifierForLogging(props.parentNode)}`,
});
let parentNode = props.parentNode;
if (parentNode && HierarchyNode.isGeneric(parentNode) && parentNode.key.source && parentNode.key.source !== this._imodelAccess.imodelKey) {
return EMPTY;
}
if (parentNode && HierarchyNode.isInstancesNode(parentNode)) {
parentNode = {
...parentNode,
key: {
...parentNode.key,
instanceKeys: parentNode.key.instanceKeys.filter((ik) => !ik.imodelKey || ik.imodelKey === this._imodelAccess.imodelKey),
},
};
assert(HierarchyNodeKey.isInstances(parentNode.key));
if (parentNode.key.instanceKeys.length === 0) {
return EMPTY;
}
}
return this._activeHierarchyDefinition.defineHierarchyLevel({ ...defineHierarchyLevelProps, parentNode }).pipe(mergeAll(), finalize(() => doLog({
category: LOGGING_NAMESPACE_PERFORMANCE,
message: /* c8 ignore next */ () => `[${requestContext.requestId}] Received all hierarchy level definitions for ${createNodeIdentifierForLogging(props.parentNode)}`,
})));
}
createSourceNodesObservable(props) {
// pipe definitions to nodes and put "share replay" on it
return this.createHierarchyLevelDefinitionsObservable(props).pipe(mergeMap((def) => {
if (HierarchyNodesDefinition.isGenericNode(def)) {
return of({ ...def.node, key: { type: "generic", id: def.node.key, source: this._imodelAccess.imodelKey } });
}
return this._queryScheduler.scheduleSubscription(of(def.query).pipe(map((query) => filterQueryByInstanceKeys(query, props.filteredInstanceKeys)), mergeMap((query) => readNodes({
queryExecutor: this._imodelAccess,
query,
limit: props.hierarchyLevelSizeLimit,
parser: this._activeHierarchyDefinition.parseNode ? (row) => this._activeHierarchyDefinition.parseNode(row, props.parentNode) : undefined,
})), map((node) => ({
...node,
key: { ...node.key, instanceKeys: node.key.instanceKeys.map((key) => ({ ...key, imodelKey: this._imodelAccess.imodelKey })) },
}))));
}), finalize(() => doLog({
category: LOGGING_NAMESPACE_PERFORMANCE,
message: /* c8 ignore next */ () => `[${props.requestContext.requestId}] Read all child nodes ${createNodeIdentifierForLogging(props.parentNode)}`,
})), shareReplayWithErrors());
}
createPreProcessedNodesObservable(queryNodesObservable, props) {
return queryNodesObservable.pipe(
// we're going to be mutating the nodes, but don't want to mutate the original one, so just clone it here once
map((node) => ({ ...node })),
// set parent node keys on the source node
map((node) => Object.assign(node, { parentKeys: createParentNodeKeysList(props.parentNode) })),
// format `ConcatenatedValue` labels into string labels
mergeMap((node) => applyLabelsFormatting(node, this._valuesFormatter)),
// we have `ProcessedHierarchyNode` from here
preProcessNodes(this._activeHierarchyDefinition),
// process hiding
createHideIfNoChildrenOperator((n) => this.getChildNodesObservables({ parentNode: n, requestContext: props.requestContext }).hasNodes), createHideNodesInHierarchyOperator(
// note: for child nodes created because of hidden parent, we want to use parent's request props (instance filter, limit)
(n) => this.getChildNodesObservables({ ...props, parentNode: n }).processedNodes, false), finalize(() => doLog({
category: LOGGING_NAMESPACE_PERFORMANCE,
message: /* c8 ignore next */ () => `[${props.requestContext.requestId}] Finished pre-processing child nodes for ${createNodeIdentifierForLogging(props.parentNode)}`,
})));
}
createProcessedNodesObservable(preprocessedNodesObservable, props) {
return preprocessedNodesObservable.pipe(createGroupingOperator(this._imodelAccess, props.parentNode, this._valuesFormatter, this._localizedStrings, undefined, (gn) => this.onGroupingNodeCreated(gn, props)), finalize(() => doLog({
category: LOGGING_NAMESPACE_PERFORMANCE,
message: /* c8 ignore next */ () => `[${props.requestContext.requestId}] Finished grouping child nodes for ${createNodeIdentifierForLogging(props.parentNode)}`,
})));
}
createFinalizedNodesObservable(processedNodesObservable, props) {
return processedNodesObservable.pipe(createDetermineChildrenOperator((n) => this.getChildNodesObservables({ parentNode: n, requestContext: props.requestContext }).hasNodes), postProcessNodes(this._activeHierarchyDefinition), sortNodesByLabelOperator, map((n) => {
if ("processingParams" in n) {
delete n.processingParams;
}
return Object.assign(n, {
children: hasChildren(n),
});
}), takeUntil(this._dispose), finalize(() => doLog({
category: LOGGING_NAMESPACE_PERFORMANCE,
message: /* c8 ignore next */ () => `[${props.requestContext.requestId}] Finished finalizing child nodes for ${createNodeIdentifierForLogging(props.parentNode)}`,
})));
}
createHasNodesObservable(preprocessedNodesObservable, possiblyKnownChildrenObservable, props) {
const loggingCategory = `${LOGGING_NAMESPACE_INTERNAL}.HasNodes`;
return concat((possiblyKnownChildrenObservable ?? EMPTY).pipe(filter((n) => hasChildren(n))), preprocessedNodesObservable).pipe(log({
category: loggingCategory,
message: /* c8 ignore next */ (n) => `[${props.requestContext.requestId}] Node before mapping to 'true': ${createNodeIdentifierForLogging(n)}`,
}), take(1), map(() => true), defaultIfEmpty(false), catchError((e) => {
doLog({
category: loggingCategory,
message: /* c8 ignore next */ () => `[${props.requestContext.requestId}] Error while determining children: ${e.message}`,
});
if (e instanceof RowsLimitExceededError) {
return of(true);
}
throw e;
}), log({ category: loggingCategory, message: /* c8 ignore next */ (r) => `[${props.requestContext.requestId}] Result: ${r}` }));
}
getCachedObservableEntry(props) {
const loggingCategory = `${LOGGING_NAMESPACE}.QueryResultsCache`;
const { parentNode, ...restProps } = props;
const cached = props.ignoreCache || !this._nodesCache ? undefined : this._nodesCache.get(props);
if (cached) {
doLog({
category: loggingCategory,
message: /* c8 ignore next */ () => `[${props.requestContext.requestId}] Found query nodes observable for ${createNodeIdentifierForLogging(parentNode)}`,
});
return cached;
}
// if we don't find an entry for a grouping node, we load its instances by getting a query and applying
// a filter based on grouped instance keys
let filteredInstanceKeys;
let parentNonGroupingNode;
if (parentNode) {
if (HierarchyNode.isGroupingNode(parentNode)) {
parentNonGroupingNode = parentNode.nonGroupingAncestor;
filteredInstanceKeys = parentNode.groupedInstanceKeys;
}
else {
// not sure why type checker doesn't pick this up
assert(HierarchyNode.isGeneric(parentNode) || HierarchyNode.isInstancesNode(parentNode));
parentNonGroupingNode = parentNode;
}
}
const nonGroupingNodeChildrenRequestProps = {
...restProps,
parentNode: parentNonGroupingNode,
...(filteredInstanceKeys ? { filteredInstanceKeys } : undefined),
};
const value = { observable: this.createSourceNodesObservable(nonGroupingNodeChildrenRequestProps), processingStatus: "none" };
this._nodesCache?.set(nonGroupingNodeChildrenRequestProps, value);
doLog({
category: loggingCategory,
message: /* c8 ignore next */ () => `[${props.requestContext.requestId}] Saved query nodes observable for ${createNodeIdentifierForLogging(parentNode)}`,
});
return value;
}
getChildNodesObservables(props) {
const entry = this.getCachedObservableEntry(props);
switch (entry.processingStatus) {
case "none": {
const pre = this.createPreProcessedNodesObservable(entry.observable, props);
const post = this.createProcessedNodesObservable(pre, props);
return {
processedNodes: post,
hasNodes: this.createHasNodesObservable(pre, entry.observable, props),
finalizedNodes: this.createFinalizedNodesObservable(post, props),
};
}
case "pre-processed": {
const post = this.createProcessedNodesObservable(entry.observable, props);
return {
processedNodes: post,
hasNodes: this.createHasNodesObservable(entry.observable, undefined, props),
finalizedNodes: this.createFinalizedNodesObservable(post, props),
};
}
}
}
/**
* Creates and runs a query based on provided props, then processes retrieved nodes and returns them.
*/
getNodes(props) {
const requestContext = { requestId: Guid.createValue() };
const loggingCategory = `${LOGGING_NAMESPACE}.GetNodes`;
const timer = new StopWatch(undefined, true);
let error;
let nodesCount = 0;
doLog({
category: loggingCategory,
message: /* c8 ignore next */ () => `[${requestContext.requestId}] Requesting child nodes for ${createNodeIdentifierForLogging(props.parentNode)}`,
});
return eachValueFrom(this.getChildNodesObservables({ ...props, requestContext }).finalizedNodes.pipe(tap(() => ++nodesCount), catchError((e) => {
error = e;
throw e;
}), finalize(() => {
doLog({
category: loggingCategory,
message: /* c8 ignore next */ () =>
/* c8 ignore next 3 */ error
? `[${requestContext.requestId}] Error creating child nodes for ${createNodeIdentifierForLogging(props.parentNode)}: ${error instanceof Error ? error.message : error.toString()}`
: `[${requestContext.requestId}] Returned ${nodesCount} child nodes for ${createNodeIdentifierForLogging(props.parentNode)} in ${timer.currentSeconds.toFixed(2)} s.`,
});
})));
}
getNodeInstanceKeysObs(props) {
const { parentNode, instanceFilter, hierarchyLevelSizeLimit = "unbounded", requestContext } = props;
if (parentNode && HierarchyNode.isGroupingNode(parentNode)) {
return from(parentNode.groupedInstanceKeys);
}
assert(!parentNode || HierarchyNode.isGeneric(parentNode) || HierarchyNode.isInstancesNode(parentNode));
const hierarchyLevelDefinitions = this.createHierarchyLevelDefinitionsObservable({ parentNode, instanceFilter, requestContext });
// split the definitions based on whether they're for generic nodes or for instance nodes
const [genericDefs, instanceDefs] = partition(hierarchyLevelDefinitions, HierarchyNodesDefinition.isGenericNode);
// query instance keys and a flag whether they should be hidden
const instanceKeys = instanceDefs.pipe(mergeMap((def) => this._queryScheduler.scheduleSubscription(of(def.query).pipe(mergeMap(async (query) => {
const ecsql = `
SELECT
${NodeSelectClauseColumnNames.FullClassName},
${NodeSelectClauseColumnNames.ECInstanceId},
${NodeSelectClauseColumnNames.HideNodeInHierarchy}
FROM (
${query.ecsql}
)
`;
const reader = this._imodelAccess.createQueryReader({ ...query, ecsql }, { rowFormat: "Indexes", limit: hierarchyLevelSizeLimit });
return from(reader).pipe(map((row) => ({
key: {
className: normalizeFullClassName(row[0]),
id: row[1],
imodelKey: this._imodelAccess.imodelKey,
},
hide: !!row[2],
})));
}), mergeAll()))));
// split the instance keys observable based on whether they should be hidden or not
const [visibleNodeInstanceKeys, hiddenNodeInstanceKeys] = partition(instanceKeys, ({ hide }) => !hide);
// hidden items' handling:
// - if a generic node is hidden, we'll want to retrieve instance keys of its children, otherwise we don't care about it,
// - if an instance key is hidden, we want to create a merged node similar to what we do in `createHideNodesInHierarchyOperator`
const hiddenParentNodes = merge(genericDefs.pipe(filter((def) => !!def.node.processingParams?.hideInHierarchy), map((def) => ({
type: "generic",
id: def.node.key,
source: this._imodelAccess.imodelKey,
}))), hiddenNodeInstanceKeys.pipe(
// first merge all keys by class
reduceToMergeMapList(({ key }) => key.className, ({ key }) => key.id),
// then, for each class, create an instance key
mergeMap((mergedMap) => [...mergedMap.entries()].map(([className, ids]) => ({
type: "instances",
instanceKeys: ids.map((id) => ({ className, id, imodelKey: this._imodelAccess.imodelKey })),
}))))).pipe(map((key) => ({
key,
parentKeys: [],
label: "",
})));
// merge visible instance keys from this level & the ones we get recursively requesting from deeper levels
return merge(visibleNodeInstanceKeys.pipe(map(({ key }) => key)), hiddenParentNodes.pipe(mergeMap((hiddenNode) => this.getNodeInstanceKeysObs({ parentNode: hiddenNode, requestContext })))).pipe(takeUntil(this._dispose));
}
/**
* Creates an iterator for all child hierarchy level instance keys, taking into account any hidden hierarchy levels
* that there may be under the given parent node.
*/
getNodeInstanceKeys(props) {
const requestContext = { requestId: Guid.createValue() };
const loggingCategory = `${LOGGING_NAMESPACE}.GetNodeInstanceKeys`;
const timer = new StopWatch(undefined, true);
doLog({
category: loggingCategory,
message: /* c8 ignore next */ () => `[${requestContext.requestId}] Requesting keys for ${createNodeIdentifierForLogging(props.parentNode)}`,
});
let error;
let keysCount = 0;
return eachValueFrom(this.getNodeInstanceKeysObs({ ...props, requestContext }).pipe(tap(() => ++keysCount), catchError((e) => {
error = e;
throw e;
}), finalize(() => {
doLog({
category: loggingCategory,
message: /* c8 ignore next */ () =>
/* c8 ignore next 3 */ error
? `[${requestContext.requestId}] Error creating node instance keys for ${createNodeIdentifierForLogging(props.parentNode)}: ${error instanceof Error ? error.message : error.toString()}`
: `[${requestContext.requestId}] Returned ${keysCount} instance keys for ${createNodeIdentifierForLogging(props.parentNode)} in ${timer.currentSeconds.toFixed(2)} s.`,
});
})));
}
}
function preProcessNodes(hierarchyFactory) {
return hierarchyFactory.preProcessNode ? processNodes(hierarchyFactory.preProcessNode) : identity;
}
function postProcessNodes(hierarchyFactory) {
return hierarchyFactory.postProcessNode ? processNodes(hierarchyFactory.postProcessNode) : identity;
}
function processNodes(processor) {
return (nodes) => nodes.pipe(mergeMap(processor));
}
function applyLabelsFormatting(node, valueFormatter) {
return from(formatConcatenatedValue({
value: node.label,
valueFormatter,
})).pipe(map((label) => ({
...node,
label,
})));
}
function createParentNodeKeysList(parentNode) {
if (!parentNode) {
return [];
}
return [...parentNode.parentKeys, parentNode.key];
}
function filterQueryByInstanceKeys(query, filteredInstanceKeys) {
if (!filteredInstanceKeys || !filteredInstanceKeys.length) {
return query;
}
const MAX_ALLOWED_BINDINGS = 1000;
if (filteredInstanceKeys.length < MAX_ALLOWED_BINDINGS) {
return {
...query,
ecsql: `
SELECT *
FROM (${query.ecsql}) q
WHERE q.ECInstanceId IN (${filteredInstanceKeys.map(() => "?").join(",")})
`,
bindings: [...(query.bindings ?? []), ...filteredInstanceKeys.map((k) => ({ type: "id", value: k.id }))],
};
}
/* c8 ignore start */
return {
...query,
ecsql: `
SELECT *
FROM (${query.ecsql}) q
WHERE InVirtualSet(?, q.ECInstanceId)
`,
bindings: [...(query.bindings ?? []), { type: "idset", value: filteredInstanceKeys.map((k) => k.id) }],
};
/* c8 ignore end */
}
//# sourceMappingURL=IModelHierarchyProvider.js.map