@itwin/presentation-hierarchies
Version:
A package for creating hierarchies based on data in iTwin.js iModels.
467 lines • 23.5 kB
JavaScript
;
/*---------------------------------------------------------------------------------------------
* Copyright (c) Bentley Systems, Incorporated. All rights reserved.
* See LICENSE.md in the project root for license terms and full copyright notice.
*--------------------------------------------------------------------------------------------*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.NodeSelectClauseColumnNames = void 0;
exports.createNodesQueryClauseFactory = createNodesQueryClauseFactory;
const core_bentley_1 = require("@itwin/core-bentley");
const core_common_1 = require("@itwin/core-common");
const presentation_shared_1 = require("@itwin/presentation-shared");
/**
* Column names of the SELECT clause created by `NodeSelectClauseFactory`. Order of the names matches the order of columns
* created by the factory.
*
* @public
*/
var NodeSelectClauseColumnNames;
(function (NodeSelectClauseColumnNames) {
/** Full class name of the instance the node represents. Type: `string` in format `{schema name}:{class name}`. */
NodeSelectClauseColumnNames["FullClassName"] = "FullClassName";
/** ECInstance ID of the instance the node represents. Type: `Id64String`. */
NodeSelectClauseColumnNames["ECInstanceId"] = "ECInstanceId";
/** Display label. Type: `string`. */
NodeSelectClauseColumnNames["DisplayLabel"] = "DisplayLabel";
/**
* A flag indicating if the node has children. Type: `boolean`. Values:
* - `false` - node never has children.
* - `true` - node always has children.
* - NULL - unknown, needs to be determined by requesting children for the node.
*/
NodeSelectClauseColumnNames["HasChildren"] = "HasChildren";
/** A flag indicating that a node should be hidden if it has no children. Type: `boolean`. */
NodeSelectClauseColumnNames["HideIfNoChildren"] = "HideIfNoChildren";
/** A flag indicating that a node should be hidden and its children should be displayed instead. Type: `boolean`. */
NodeSelectClauseColumnNames["HideNodeInHierarchy"] = "HideNodeInHierarchy";
/** A serialized JSON object for providing grouping information. */
NodeSelectClauseColumnNames["Grouping"] = "Grouping";
/** A serialized JSON object for associating a node with additional data. */
NodeSelectClauseColumnNames["ExtendedData"] = "ExtendedData";
/** A flag indicating the node should be auto-expanded when it's loaded. */
NodeSelectClauseColumnNames["AutoExpand"] = "AutoExpand";
/** A flag indicating the node supports child hierarchy level filtering. */
NodeSelectClauseColumnNames["SupportsFiltering"] = "SupportsFiltering";
})(NodeSelectClauseColumnNames || (exports.NodeSelectClauseColumnNames = NodeSelectClauseColumnNames = {}));
/**
* Creates an instance of `NodeSelectQueryFactory`.
* @public
*/
function createNodesQueryClauseFactory(props) {
return new NodeSelectQueryFactory(props);
}
/** A factory for creating a nodes' select ECSQL query. */
class NodeSelectQueryFactory {
_imodelAccess;
_instanceLabelSelectClauseFactory;
constructor(props) {
this._imodelAccess = props.imodelAccess;
this._instanceLabelSelectClauseFactory = props.instanceLabelSelectClauseFactory;
}
/** Create a SELECT clause in a format understood by results reader of the library. */
async createSelectClause(props) {
// note: the columns order must match the order in `NodeSelectClauseColumnNames`
return `
ec_ClassName(${props.ecClassId.selector}) AS ${NodeSelectClauseColumnNames.FullClassName},
${props.ecInstanceId.selector} AS ${NodeSelectClauseColumnNames.ECInstanceId},
${createECSqlValueSelector(props.nodeLabel)} AS ${NodeSelectClauseColumnNames.DisplayLabel},
CAST(${createECSqlValueSelector(props.hasChildren)} AS BOOLEAN) AS ${NodeSelectClauseColumnNames.HasChildren},
CAST(${createECSqlValueSelector(props.hideIfNoChildren)} AS BOOLEAN) AS ${NodeSelectClauseColumnNames.HideIfNoChildren},
CAST(${createECSqlValueSelector(props.hideNodeInHierarchy)} AS BOOLEAN) AS ${NodeSelectClauseColumnNames.HideNodeInHierarchy},
${props.grouping ? await createGroupingSelector(props.grouping, this._imodelAccess, this._instanceLabelSelectClauseFactory) : "CAST(NULL AS TEXT)"} AS ${NodeSelectClauseColumnNames.Grouping},
${props.extendedData
? `json_object(${Object.entries(props.extendedData)
.map(([key, value]) => `'${key}', ${createECSqlValueSelector(value)}`)
.join(", ")})`
: "CAST(NULL AS TEXT)"} AS ${NodeSelectClauseColumnNames.ExtendedData},
CAST(${createECSqlValueSelector(props.autoExpand)} AS BOOLEAN) AS ${NodeSelectClauseColumnNames.AutoExpand},
CAST(${createECSqlValueSelector(props.supportsFiltering)} AS BOOLEAN) AS ${NodeSelectClauseColumnNames.SupportsFiltering}
`;
}
/**
* Creates the necessary ECSQL snippets to create an instance filter described by the `filter` argument.
* - `from` is set to either the `contentClass.fullName` or one of `filter.propertyClassNames`, depending on which is more specific.
* - `joins` is set to a number of `JOIN` clauses required to join all relationships described by `filter.relatedInstances`.
* - `where` is set to a `WHERE` clause (without the `WHERE` keyword) that filters instances by classes on
* `filter.filterClassNames` and by properties as described by `filter.rules`.
*
* Special cases:
* - If `filter` is `undefined`, `joins` and `where` are set to empty strings and `from` is set to `contentClass.fullName`.
* - If the provided content class doesn't intersect with the property class in provided filter, a special result
* is returned to make sure the resulting query is valid and doesn't return anything.
*/
async createFilterClauses(props) {
const { contentClass, filter } = props;
if (!filter) {
// undefined filter means we don't want any filtering to be applied
return { from: contentClass.fullName, joins: "", where: "" };
}
const from = await specializeContentClass({
classHierarchyInspector: this._imodelAccess,
contentClassName: contentClass.fullName,
filterClassNames: filter.propertyClassNames,
});
if (!from) {
// filter class doesn't intersect with content class - make sure the query returns nothing by returning a `FALSE` WHERE clause
return { from: contentClass.fullName, joins: "", where: "FALSE" };
}
/**
* TODO:
* There may be similar related instance paths, e.g.:
* - A -> B -> C,
* - A -> B -> D.
* At this moment we're handling each path individually which means A and B would get joined twice. We could look into
* creating a join tree first, e.g.
* C
* /
* A -> B
* \
* D
*/
const joins = await Promise.all(filter.relatedInstances.map(async (rel, i) => presentation_shared_1.ECSql.createRelationshipPathJoinClause({
schemaProvider: this._imodelAccess,
path: assignRelationshipPathAliases(rel.path, i, contentClass.alias, rel.alias),
})));
const whereConditions = new Array();
if (filter.filteredClassNames && filter.filteredClassNames.length > 0) {
whereConditions.push(`${presentation_shared_1.ECSql.createRawPropertyValueSelector(contentClass.alias, "ECClassId")} IS (
${filter.filteredClassNames
.map(presentation_shared_1.parseFullClassName)
.map(({ schemaName, className }) => `[${schemaName}].[${className}]`)
.join(", ")}
)`);
}
const classAliasMap = new Map([[contentClass.alias, from]]);
filter.relatedInstances.forEach(({ path, alias }) => path.length > 0 && classAliasMap.set(alias, path[path.length - 1].targetClassName));
const propertiesFilter = await createWhereClause(contentClass.alias, async (alias) => {
const aliasClassName = classAliasMap.get(alias);
return aliasClassName ? (0, presentation_shared_1.getClass)(this._imodelAccess, aliasClassName) : undefined;
}, filter.rules);
if (propertiesFilter) {
whereConditions.push(propertiesFilter);
}
return { from, joins: joins.join("\n"), where: whereConditions.join(" AND ") };
}
}
function createECSqlValueSelector(input) {
if (input === undefined) {
return "NULL";
}
if (isSelector(input)) {
return input.selector;
}
return presentation_shared_1.ECSql.createRawPrimitiveValueSelector(input);
}
function isSelector(x) {
return !!x.selector;
}
async function createGroupingSelector(grouping, imodelAccess, instanceLabelSelectClauseFactory) {
const groupingSelectors = new Array();
grouping.byLabel &&
groupingSelectors.push({
key: "byLabel",
selector: typeof grouping.byLabel === "boolean" || isSelector(grouping.byLabel)
? createECSqlValueSelector(grouping.byLabel)
: serializeJsonObject(createLabelGroupingBaseParamsSelectors(grouping.byLabel)),
});
grouping.byClass &&
groupingSelectors.push({
key: "byClass",
selector: typeof grouping.byClass === "boolean" || isSelector(grouping.byClass)
? createECSqlValueSelector(grouping.byClass)
: serializeJsonObject(createBaseGroupingParamSelectors(grouping.byClass)),
});
grouping.byBaseClasses &&
groupingSelectors.push({
key: "byBaseClasses",
selector: serializeJsonObject([
{
key: "fullClassNames",
selector: `json_array(${grouping.byBaseClasses.fullClassNames.map((className) => createECSqlValueSelector(className)).join(", ")})`,
},
...createBaseGroupingParamSelectors(grouping.byBaseClasses),
]),
});
// TODO: MISSING_COVERAGE
/* v8 ignore else -- @preserve */
if (grouping.byProperties) {
const propertyClass = await (0, presentation_shared_1.getClass)(imodelAccess, grouping.byProperties.propertiesClassName);
groupingSelectors.push({
key: "byProperties",
selector: serializeJsonObject([
{
key: "propertiesClassName",
selector: `${createECSqlValueSelector(grouping.byProperties.propertiesClassName)}`,
},
{
key: "propertyGroups",
selector: `json_array(${(await Promise.all(grouping.byProperties.propertyGroups.map(async (propertyGroup) => serializeJsonObject(await createPropertyGroupSelectors(propertyGroup, propertyClass, instanceLabelSelectClauseFactory))))).join(", ")})`,
},
...(grouping.byProperties.createGroupForOutOfRangeValues !== undefined
? [
{
key: "createGroupForOutOfRangeValues",
selector: `CAST(${createECSqlValueSelector(grouping.byProperties.createGroupForOutOfRangeValues)} AS BOOLEAN)`,
},
]
: []),
...(grouping.byProperties.createGroupForUnspecifiedValues !== undefined
? [
{
key: "createGroupForUnspecifiedValues",
selector: `CAST(${createECSqlValueSelector(grouping.byProperties.createGroupForUnspecifiedValues)} AS BOOLEAN)`,
},
]
: []),
...createBaseGroupingParamSelectors(grouping.byProperties),
]),
});
}
return serializeJsonObject(groupingSelectors);
}
function createLabelGroupingBaseParamsSelectors(byLabel) {
const selectors = new Array();
if (byLabel.action !== undefined) {
selectors.push({ key: "action", selector: `${createECSqlValueSelector(byLabel.action)}` });
}
if (byLabel.groupId !== undefined) {
selectors.push({ key: "groupId", selector: createECSqlValueSelector(byLabel.groupId) });
}
if (byLabel.action !== "merge") {
selectors.push(...createBaseGroupingParamSelectors(byLabel));
}
return selectors;
}
async function createPropertyGroupSelectors(propertyGroup, propertyClass, instanceLabelSelectClauseFactory) {
const selectors = new Array();
selectors.push({ key: "propertyName", selector: `${createECSqlValueSelector(propertyGroup.propertyName)}` });
const property = await propertyClass.getProperty(propertyGroup.propertyName);
if (!property) {
throw new Error(`Property "${propertyGroup.propertyName}" not found in ECClass "${propertyClass.fullName}".`);
}
if (property.isNavigation()) {
const relationshipClass = await property.relationshipClass;
const abstractConstraint = property.direction === "Forward"
? await relationshipClass.target.abstractConstraint
: await relationshipClass.source.abstractConstraint;
// TODO: MISSING_COVERAGE
/* v8 ignore else -- @preserve */
if (!abstractConstraint) {
throw new Error(`Could not determine class name for navigation property with direction "${property.direction}".`);
}
const fullName = abstractConstraint.fullName;
const targetAlias = "target";
selectors.push({
key: "propertyValue",
selector: `(
SELECT ${await instanceLabelSelectClauseFactory.createSelectClause({ className: fullName, classAlias: targetAlias })}
FROM ${fullName} AS ${targetAlias}
WHERE [${targetAlias}].[ECInstanceId] = [${propertyGroup.propertyClassAlias}].[${propertyGroup.propertyName}].[Id]
)`,
});
}
else {
selectors.push({
key: "propertyValue",
selector: `[${propertyGroup.propertyClassAlias}].[${propertyGroup.propertyName}]`,
});
}
if (propertyGroup.ranges) {
selectors.push(createRangeParamSelectors(propertyGroup.ranges));
}
return selectors;
}
function createRangeParamSelectors(ranges) {
return {
key: "ranges",
selector: `json_array(${ranges
.map((range) => serializeJsonObject([
{ key: "fromValue", selector: createECSqlValueSelector(range.fromValue) },
{ key: "toValue", selector: createECSqlValueSelector(range.toValue) },
...(range.rangeLabel
? [{ key: "rangeLabel", selector: `${createECSqlValueSelector(range.rangeLabel)}` }]
: []),
]))
.join(", ")})`,
};
}
function createBaseGroupingParamSelectors(params) {
const selectors = new Array();
if (params.hideIfNoSiblings !== undefined) {
selectors.push({ key: "hideIfNoSiblings", selector: createECSqlValueSelector(params.hideIfNoSiblings) });
}
if (params.hideIfOneGroupedNode !== undefined) {
selectors.push({ key: "hideIfOneGroupedNode", selector: createECSqlValueSelector(params.hideIfOneGroupedNode) });
}
if (params.autoExpand !== undefined) {
selectors.push({ key: "autoExpand", selector: createECSqlValueSelector(params.autoExpand) });
}
return selectors;
}
function serializeJsonObject(selectors) {
return `json_object(${selectors.map(({ key, selector }) => `'${key}', ${selector}`).join(", ")})`;
}
async function createWhereClause(contentClassAlias, classLoader, rule) {
if (core_common_1.GenericInstanceFilter.isFilterRuleGroup(rule)) {
const clause = (await Promise.all(rule.rules.map(async (r) => createWhereClause(contentClassAlias, classLoader, r))))
.filter((c) => !!c)
.join(` ${getECSqlLogicalOperator(rule.operator)} `);
return clause ? (rule.operator === "or" ? `(${clause})` : clause) : undefined;
}
const sourceAlias = rule.sourceAlias ? rule.sourceAlias : contentClassAlias;
const propertyValueSelector = presentation_shared_1.ECSql.createRawPropertyValueSelector(sourceAlias, rule.propertyName);
if (isUnaryRuleOperator(rule.operator)) {
switch (rule.operator) {
case "is-true":
return propertyValueSelector;
case "is-false":
return `NOT ${propertyValueSelector}`;
case "is-null":
return `${propertyValueSelector} IS NULL`;
case "is-not-null":
return `${propertyValueSelector} IS NOT NULL`;
}
}
const ecsqlOperator = getECSqlComparisonOperator(rule.operator);
if (rule.value === undefined) {
throw new Error(`Rule "${rule.propertyName}" ${ecsqlOperator} is missing value.`);
}
const value = rule.value.rawValue;
if (rule.operator === "like" && typeof value === "string") {
const escapedValue = value.replace(/[%_\\]/g, "\\$&");
return `${propertyValueSelector} ${ecsqlOperator} '%${escapedValue}%' ESCAPE '\\'`;
}
const propertyClass = await classLoader(sourceAlias);
if (!propertyClass) {
throw new Error(`Class with alias "${sourceAlias}" not found.`);
}
const property = await propertyClass.getProperty(rule.propertyName);
if (!property) {
throw new Error(`Property "${rule.propertyName}" not found in ECClass "${propertyClass.fullName}".`);
}
if (property.isNavigation()) {
(0, core_bentley_1.assert)(rule.value !== undefined && core_common_1.GenericInstanceFilterRuleValue.isInstanceKey(value));
return `${propertyValueSelector}.[Id] ${ecsqlOperator} ${presentation_shared_1.ECSql.createRawPrimitiveValueSelector(value.id)}`;
}
if (property.isEnumeration()) {
(0, core_bentley_1.assert)(rule.value !== undefined && !core_common_1.GenericInstanceFilterRuleValue.isInstanceKey(value));
return `${propertyValueSelector} ${ecsqlOperator} ${presentation_shared_1.ECSql.createRawPrimitiveValueSelector(value)}`;
}
if (property.isPrimitive()) {
(0, core_bentley_1.assert)(rule.value !== undefined && !core_common_1.GenericInstanceFilterRuleValue.isInstanceKey(value));
switch (property.primitiveType) {
case "Point2d": {
(0, core_bentley_1.assert)(rule.operator === "is-equal" || rule.operator === "is-not-equal");
(0, core_bentley_1.assert)(presentation_shared_1.PrimitiveValue.isPoint2d(value));
const condition = `
${createFloatingPointEqualityClause(`${propertyValueSelector}.[x]`, "is-equal", value.x)}
AND ${createFloatingPointEqualityClause(`${propertyValueSelector}.[y]`, "is-equal", value.y)}
`;
return rule.operator === "is-equal" ? condition : `NOT (${condition})`;
}
case "Point3d": {
(0, core_bentley_1.assert)(rule.operator === "is-equal" || rule.operator === "is-not-equal");
(0, core_bentley_1.assert)(presentation_shared_1.PrimitiveValue.isPoint3d(value));
const condition = `
${createFloatingPointEqualityClause(`${propertyValueSelector}.[x]`, "is-equal", value.x)}
AND ${createFloatingPointEqualityClause(`${propertyValueSelector}.[y]`, "is-equal", value.y)}
AND ${createFloatingPointEqualityClause(`${propertyValueSelector}.[z]`, "is-equal", value.z)}
`;
return rule.operator === "is-equal" ? condition : `NOT (${condition})`;
}
case "Double": {
(0, core_bentley_1.assert)(typeof value === "number");
if (rule.operator === "is-equal" || rule.operator === "is-not-equal") {
return `${createFloatingPointEqualityClause(propertyValueSelector, rule.operator, value)}`;
}
return `${propertyValueSelector} ${ecsqlOperator} ${presentation_shared_1.ECSql.createRawPrimitiveValueSelector(value)}`;
}
default: {
return `${propertyValueSelector} ${ecsqlOperator} ${presentation_shared_1.ECSql.createRawPrimitiveValueSelector(value)}`;
}
}
}
throw new Error("Struct and array properties are not supported for filtering");
}
function createFloatingPointEqualityClause(valueSelector, operator, value) {
const [from, to] = getFloatingPointValueRange(value);
return `${valueSelector} ${operator === "is-not-equal" ? "NOT " : ""} BETWEEN ${from} AND ${to}`;
}
function getFloatingPointValueRange(value) {
return [value - Number.EPSILON, value + Number.EPSILON];
}
function getECSqlLogicalOperator(op) {
switch (op) {
case "and":
return "AND";
case "or":
return "OR";
}
}
function isUnaryRuleOperator(op) {
return op === "is-true" || op === "is-false" || op === "is-null" || op === "is-not-null";
}
function getECSqlComparisonOperator(op) {
switch (op) {
case "is-equal":
return `=`;
case "is-not-equal":
return `<>`;
case "greater":
return `>`;
case "greater-or-equal":
return `>=`;
case "less":
return `<`;
case "less-or-equal":
return `<=`;
case "like":
return `LIKE`;
}
}
function assignRelationshipPathAliases(path, pathIndex, sourceAlias, targetAlias) {
function createAlias(fullClassName, index) {
return `rel_${pathIndex}_${fullClassName.replaceAll(/[\.:]/g, "_")}_${index}`;
}
const result = [];
path.forEach((step, i) => {
result.push({
targetClassName: step.targetClassName,
relationshipName: step.relationshipClassName,
sourceClassName: step.sourceClassName,
relationshipReverse: !step.isForwardRelationship,
sourceAlias: i === 0 ? sourceAlias : result[i - 1].targetAlias,
relationshipAlias: createAlias(step.relationshipClassName, i),
targetAlias: i === path.length - 1 ? targetAlias : createAlias(step.targetClassName, i),
joinType: "inner",
});
});
return result;
}
async function specializeContentClass(props) {
const filterClass = await getSpecializedPropertyClass(props.classHierarchyInspector, props.filterClassNames);
if (!filterClass) {
return props.contentClassName;
}
if (await props.classHierarchyInspector.classDerivesFrom(filterClass, props.contentClassName)) {
return filterClass;
}
if (await props.classHierarchyInspector.classDerivesFrom(props.contentClassName, filterClass)) {
return props.contentClassName;
}
return undefined;
}
async function getSpecializedPropertyClass(classHierarchyInspector, classes) {
if (classes.length === 0) {
return undefined;
}
const [currClassName, ...restClasses] = classes;
let resolvedClassName = currClassName;
for (const propClassName of restClasses) {
if (await classHierarchyInspector.classDerivesFrom(propClassName, resolvedClassName)) {
resolvedClassName = propClassName;
}
}
return resolvedClassName;
}
//# sourceMappingURL=NodeSelectQueryFactory.js.map