diff --git a/change/@itwin-tree-widget-react-e0e85de3-ea1f-48ff-b8da-1cb2f72be783.json b/change/@itwin-tree-widget-react-e0e85de3-ea1f-48ff-b8da-1cb2f72be783.json new file mode 100644 index 0000000000..947dc75b50 --- /dev/null +++ b/change/@itwin-tree-widget-react-e0e85de3-ea1f-48ff-b8da-1cb2f72be783.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Fix category count query throwing error on large iModels", + "packageName": "@itwin/tree-widget-react", + "email": "100586436+JonasDov@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeIdsCache.test.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeIdsCache.test.ts index e56bc3a603..5ed71e8bbb 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeIdsCache.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeIdsCache.test.ts @@ -22,7 +22,7 @@ describe("ModelsTreeIdsCache", () => { const categoryId = "0x2"; const elementIds = ["0x10", "0x20", "0x30"]; const stub = sinon.fake((query: string) => { - if (query.includes(`WHERE Parent.Id IS NULL AND (Model.Id = ${modelId} AND Category.Id = ${categoryId})`)) { + if (query.includes(`WHERE Parent.Id IS NULL AND (Model.Id = ${modelId} AND Category.Id IN (${categoryId}))`)) { return [{ modelId, categoryId, elementsCount: elementIds.length }]; } throw new Error(`Unexpected query: ${query}`); diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts index 7cc8cab7fd..4137f07937 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/ModelsTreeVisibilityHandler.test.ts @@ -2196,6 +2196,36 @@ describe("ModelsTreeVisibilityHandler", () => { }); }); + it("validates visibility for large iModel", async function () { + await using buildIModelResult = await buildIModel(this, async (builder) => { + const modelsToTurnOn = new Array(); + let elementId = ""; + for (let i = 0; i < 3; ++i) { + const model = insertPhysicalModelWithPartition({ builder, partitionParentId: IModel.rootSubjectId, codeValue: `model${i}` }).id; + modelsToTurnOn.push(model); + for (let j = 0; j < 1000; ++j) { + const categoryId = insertSpatialCategory({ builder, codeValue: `category${i}-${j}` }).id; + elementId = insertPhysicalElement({ builder, modelId: model, categoryId }).id; + } + } + return { modelsToTurnOn, elementId }; + }); + + const { imodel, ...ids } = buildIModelResult; + using visibilityTestData = createVisibilityTestData({ imodel }); + const { handler, provider, viewport } = visibilityTestData; + await Promise.all(ids.modelsToTurnOn.map(async (modelId) => handler.changeVisibility(createModelHierarchyNode(modelId), true))); + viewport.setAlwaysDrawn(new Set([ids.elementId])); + viewport.renderFrame(); + await validateHierarchyVisibility({ + provider, + handler, + viewport, + visibilityExpectations: VisibilityExpectations.all("visible"), + waitForOptions: { timeout: 2000 }, + }); + }); + it("showing model makes it, all its categories and elements visible and doesn't affect other models", async function () { await using buildIModelResult = await buildIModel(this, async (builder) => { const categoryId = insertSpatialCategory({ builder, codeValue: "category" }).id; diff --git a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/VisibilityValidation.ts b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/VisibilityValidation.ts index 615c4f46b9..231d85669d 100644 --- a/packages/itwin/tree-widget/src/test/trees/models-tree/internal/VisibilityValidation.ts +++ b/packages/itwin/tree-widget/src/test/trees/models-tree/internal/VisibilityValidation.ts @@ -11,11 +11,13 @@ import { toVoidPromise } from "../../../../tree-widget-react/components/trees/co import { ModelsTreeNode } from "../../../../tree-widget-react/components/trees/models-tree/internal/ModelsTreeNode.js"; import { waitFor } from "../../../TestUtils.js"; +import type { waitForOptions } from "../../../TestUtils.js"; import type { Visibility } from "../../../../tree-widget-react/components/trees/common/Tooltip.js"; import type { Id64Array, Id64String } from "@itwin/core-bentley"; import type { Viewport } from "@itwin/core-frontend"; import type { HierarchyProvider } from "@itwin/presentation-hierarchies"; import type { HierarchyVisibilityHandler } from "../../../../tree-widget-react/components/trees/common/UseHierarchyVisibility.js"; + interface VisibilityExpectations { subject(id: string): Visibility; element(props: { modelId: Id64String; categoryId: Id64String; elementId: Id64String }): Visibility; @@ -115,15 +117,17 @@ export async function validateNodeVisibility({ node, handler, visibilityExpectat export async function validateHierarchyVisibility({ provider, + waitForOptions, ...props }: Omit & { visibilityExpectations: VisibilityExpectations; provider: HierarchyProvider; + waitForOptions?: waitForOptions; }) { await toVoidPromise( from(provider.getNodes({ parentNode: undefined })).pipe( expand((node) => provider.getNodes({ parentNode: node })), - mergeMap(async (node) => waitFor(async () => validateNodeVisibility({ ...props, node }))), + mergeMap(async (node) => waitFor(async () => validateNodeVisibility({ ...props, node }), waitForOptions)), ), ); } diff --git a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.ts b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.ts index 1579798e8e..1297491174 100644 --- a/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.ts +++ b/packages/itwin/tree-widget/src/tree-widget-react/components/trees/models-tree/internal/ModelsTreeIdsCache.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import type { Subscription } from "rxjs"; -import { bufferTime, filter, firstValueFrom, mergeAll, mergeMap, ReplaySubject, Subject } from "rxjs"; +import { bufferCount, bufferTime, filter, firstValueFrom, from, map, mergeAll, mergeMap, reduce, ReplaySubject, Subject } from "rxjs"; import { assert, Id64 } from "@itwin/core-bentley"; import { IModel } from "@itwin/core-common"; +import { collect } from "../../common/Rxjs.js"; import { pushToMap } from "../../common/Utils.js"; import type { InstanceKey } from "@itwin/presentation-shared"; @@ -388,41 +389,63 @@ export class ModelsTreeIdsCache { private async queryCategoryElementCounts( input: Array<{ modelId: Id64String; categoryId: Id64String }>, ): Promise> { - const reader = this._queryExecutor.createQueryReader( - { - ctes: [ - /* sql */ ` - CategoryElements(id, modelId, categoryId) AS ( - SELECT ECInstanceId, Model.Id, Category.Id - FROM ${this._hierarchyConfig.elementClassSpecification} - WHERE - Parent.Id IS NULL - AND ( - ${input.map(({ modelId, categoryId }) => `Model.Id = ${modelId} AND Category.Id = ${categoryId}`).join(" OR ")} - ) - - UNION ALL - - SELECT c.ECInstanceId, p.modelId, p.categoryId - FROM ${this._hierarchyConfig.elementClassSpecification} c - JOIN CategoryElements p ON c.Parent.Id = p.id - ) - `, - ], - ecsql: ` - SELECT modelId, categoryId, COUNT(id) elementsCount - FROM CategoryElements - GROUP BY modelId, categoryId - `, - }, - { rowFormat: "ECSqlPropertyNames", limit: "unbounded" }, + return collect( + from(input).pipe( + reduce((acc, { modelId, categoryId }) => { + const entry = acc.get(modelId); + if (!entry) { + acc.set(modelId, new Set([categoryId])); + } else { + entry.add(categoryId); + } + return acc; + }, new Map()), + mergeMap((modelCategoryMap) => modelCategoryMap.entries()), + map(([modelId, categoryIds]) => `Model.Id = ${modelId} AND Category.Id IN (${[...categoryIds].join(", ")})`), + // Maximum Depth Of An Expression Tree is set to 3000: + // https://github.com/iTwin/imodel-native/blob/f0f36d97fe10fd441b8bf760c331d299615a42b9/iModelCore/BeSQLite/SQLite/bentley-sqlite.c#L22 + // 2900 makes sure that this limit is not reached + bufferCount(2900), + mergeMap(async (whereClauses) => { + const reader = this._queryExecutor.createQueryReader( + { + ctes: [ + ` + CategoryElements(id, modelId, categoryId) AS ( + SELECT ECInstanceId, Model.Id, Category.Id + FROM ${this._hierarchyConfig.elementClassSpecification} + WHERE + Parent.Id IS NULL + AND ( + ${whereClauses.join(" OR ")} + ) + + UNION ALL + + SELECT c.ECInstanceId, p.modelId, p.categoryId + FROM ${this._hierarchyConfig.elementClassSpecification} c + JOIN CategoryElements p ON c.Parent.Id = p.id + ) + `, + ], + ecsql: ` + SELECT modelId, categoryId, COUNT(id) elementsCount + FROM CategoryElements + GROUP BY modelId, categoryId + `, + }, + { rowFormat: "ECSqlPropertyNames", limit: "unbounded" }, + ); + + const result = new Array<{ modelId: number; categoryId: number; elementsCount: number }>(); + for await (const row of reader) { + result.push({ modelId: row.modelId, categoryId: row.categoryId, elementsCount: row.elementsCount }); + } + return result; + }), + mergeAll(), + ), ); - - const result = new Array<{ modelId: number; categoryId: number; elementsCount: number }>(); - for await (const row of reader) { - result.push({ modelId: row.modelId, categoryId: row.categoryId, elementsCount: row.elementsCount }); - } - return result; } public async getCategoryElementsCount(modelId: Id64String, categoryId: Id64String): Promise {