diff --git a/.changeset/yummy-lands-think.md b/.changeset/yummy-lands-think.md new file mode 100644 index 000000000..80cec6a13 --- /dev/null +++ b/.changeset/yummy-lands-think.md @@ -0,0 +1,5 @@ +--- +"@itwin/presentation-shared": minor +--- + +`ECSql.createRelationshipPathJoinInfo`: The `joins` array moved from the top-level result into each entry of the returned `steps[]`, so every step now carries its own ordered `joins`. In addition, each step exposes selectors for the path step's concrete `ECClassId` — `relationshipClassIdSelector`, `sourceClassIdSelector` (`[sourceAlias].[ECClassId]`) and `targetClassIdSelector` (`[targetAlias].[ECClassId]`). For a link-table step the relationship selector is `[relationshipAlias].[ECClassId]`; for a navigation-property step it is `[ownerAlias].[navigationProperty].[RelECClassId]`. For `outer` joins the relationship and target selectors yield `NULL` when nothing is related. diff --git a/packages/content/src/content/ContentTarget.ts b/packages/content/src/content/ContentTarget.ts index 7df833f41..395dd6237 100644 --- a/packages/content/src/content/ContentTarget.ts +++ b/packages/content/src/content/ContentTarget.ts @@ -123,6 +123,11 @@ export interface ContentSource { export interface ResolvedPath { /** * The concrete relationship path from the content target to the related property source. + * + * Every class on each step is concrete — the step's `sourceClassName`, `targetClassName` and + * `relationshipName` are all resolved from the scanned data, never a polymorphically-selected base. + * For a polymorphic relationship this means the concrete relationship subclass(es) actually present + * in the data; distinct subclasses are reported as separate `ResolvedPath` entries. */ path: RelationshipPath; diff --git a/packages/content/src/content/ResolveContentSources.ts b/packages/content/src/content/ResolveContentSources.ts index 009991502..8bd1f5feb 100644 --- a/packages/content/src/content/ResolveContentSources.ts +++ b/packages/content/src/content/ResolveContentSources.ts @@ -76,17 +76,37 @@ function buildTargetFilter(target: ContentTarget): { }; } -function buildClassNameColumns(path: JoinRelationshipPath): string { - return path - .map((step: JoinRelationshipPath[number]) => `ec_classname([${step.targetAlias}].[ECClassId], 's.c')`) - .join(", "); +// Concrete class-name columns for a set of per-step class-id selectors +function buildClassNameColumns(selectors: string[]): string { + return selectors.map((selector) => `ec_classname(${selector}, 's.c')`).join(", "); } -// The `ECClassId` columns behind `buildClassNameColumns` — used for `GROUP BY`. Grouping on the -// raw indexed `ECClassId` lets the engine use its index, unlike `DISTINCT` on the computed -// `ec_classname(...)` string. -function buildClassIdColumns(path: JoinRelationshipPath): string { - return path.map((step: JoinRelationshipPath[number]) => `[${step.targetAlias}].[ECClassId]`).join(", "); +// The raw class-id selectors themselves — used for `GROUP BY`. Grouping on the raw indexed +// `ECClassId` lets the engine use its index, unlike `DISTINCT` on the computed `ec_classname(...)` +// string. +function buildClassIdColumns(selectors: string[]): string { + return selectors.join(", "); +} + +// Resolves a relationship path into its JOIN clause plus the per-step concrete relationship/target +// `ECClassId` selectors. +async function resolveJoin( + schemaProvider: ECSchemaProvider, + path: JoinRelationshipPath, +): Promise<{ + joins: string; + bindings?: Record; + selectors: Array<{ relationshipClassId: string; targetClassId: string }>; +}> { + const info = await ECSql.createRelationshipPathJoinInfo({ schemaProvider, path }); + const joinClause = ECSql.createRelationshipPathJoinClause(info); + return { + ...joinClause, + selectors: info.steps.map((step) => ({ + relationshipClassId: step.relationshipClassIdSelector, + targetClassId: step.targetClassIdSelector, + })), + }; } // Distinct-class scan of the primary itself: enumerates the concrete classes that actually @@ -117,19 +137,17 @@ const originalStrategy: ResolutionQueryStrategy = { }, async buildQuery(ctx) { const { target, joinPath, schemaProvider } = ctx; - const { joins, bindings: joinBindings } = await ECSql.createRelationshipPathJoinClause({ - schemaProvider, - path: joinPath, - }); + const { joins, bindings: joinBindings, selectors } = await resolveJoin(schemaProvider, joinPath); + const classSelectors = selectors.flatMap((s) => [s.relationshipClassId, s.targetClassId]); const targetFilter = buildTargetFilter(target); const whereClause = targetFilter.where ? `WHERE ${targetFilter.where}` : ""; const allBindings = { ...joinBindings, ...targetFilter.bindings }; const ecsql = ` - SELECT GROUP_CONCAT(DISTINCT ec_classname([this].[ECClassId], 's.c')), ${buildClassNameColumns(joinPath)} + SELECT GROUP_CONCAT(DISTINCT ec_classname([this].[ECClassId], 's.c')), ${buildClassNameColumns(classSelectors)} FROM ${ECSql.createClassSelector(target.primaryClass)} [this] ${joins} ${targetFilter.joins ?? ""} ${whereClause} - GROUP BY ${buildClassIdColumns(joinPath)} + GROUP BY ${buildClassIdColumns(classSelectors)} `; return { ecsql, ...(Object.keys(allBindings).length > 0 ? { bindings: allBindings } : {}) }; }, @@ -149,13 +167,13 @@ const rewriteStrategy: ResolutionQueryStrategy = { const targetFilter = buildTargetFilter(target); const [ - { joins: firstStepJoins, bindings: firstStepBindings }, - { joins: remainingJoins, bindings: remainingBindings }, + { joins: firstStepJoins, bindings: firstStepBindings, selectors: firstStepSelectors }, + { joins: remainingJoins, bindings: remainingBindings, selectors: remainingSelectors }, ] = await Promise.all([ // First step joins (for the subquery anchoring at source) - ECSql.createRelationshipPathJoinClause({ schemaProvider, path: [joinPath[0]] }), + resolveJoin(schemaProvider, [joinPath[0]]), // Remaining step joins (for the outer query anchored at first hop's target) - ECSql.createRelationshipPathJoinClause({ schemaProvider, path: joinPath.slice(1) }), + resolveJoin(schemaProvider, joinPath.slice(1)), ]); // Anchor at first hop's target class @@ -163,23 +181,29 @@ const rewriteStrategy: ResolutionQueryStrategy = { const firstHopTarget = firstStep.targetClassName; const firstHopAlias = firstStep.targetAlias; + const firstStepRelSelector = firstStepSelectors[0].relationshipClassId; + + const classSelectors = [ + ...firstStepSelectors.map((s) => s.targetClassId), + ...remainingSelectors.flatMap((s) => [s.relationshipClassId, s.targetClassId]), + ]; const instanceFilterClauses = targetFilter.where ? `WHERE ${targetFilter.where}` : ""; // The inner scan is anchored at the (large) source, but only ever yields a small set of // DISTINCT (first-hop class, near-end class) id pairs. Joining that derived table keeps the // outer scan anchored at the first hop while still projecting the concrete near-end class. const ecsql = ` - SELECT GROUP_CONCAT(DISTINCT ec_classname([reachable].[NearEndClassId], 's.c')), ${buildClassNameColumns(joinPath)} + SELECT GROUP_CONCAT(DISTINCT ec_classname([reachable].[NearEndClassId], 's.c')), ec_classname([reachable].[FirstStepRelClassId], 's.c'), ${buildClassNameColumns(classSelectors)} FROM ${ECSql.createClassSelector(firstHopTarget)} [${firstHopAlias}] ${remainingJoins} INNER JOIN ( - SELECT [${firstHopAlias}].[ECClassId] [FirstHopClassId], [this].[ECClassId] [NearEndClassId] + SELECT [${firstHopAlias}].[ECClassId] [FirstHopClassId], [this].[ECClassId] [NearEndClassId], ${firstStepRelSelector} [FirstStepRelClassId] FROM ${ECSql.createClassSelector(target.primaryClass)} [this] ${firstStepJoins} ${targetFilter.joins ?? ""} ${instanceFilterClauses} - GROUP BY [${firstHopAlias}].[ECClassId], [this].[ECClassId] + GROUP BY [${firstHopAlias}].[ECClassId], [this].[ECClassId], ${firstStepRelSelector} ) [reachable] ON [reachable].[FirstHopClassId] = [${firstHopAlias}].[ECClassId] - GROUP BY ${buildClassIdColumns(joinPath)} + GROUP BY [reachable].[FirstStepRelClassId], ${buildClassIdColumns(classSelectors)} `; const allBindings = { ...firstStepBindings, ...remainingBindings, ...targetFilter.bindings }; @@ -197,20 +221,18 @@ const crossJoinStrategy: ResolutionQueryStrategy = { }, async buildQuery(ctx) { const { target, joinPath, schemaProvider } = ctx; - const { joins, bindings: joinBindings } = await ECSql.createRelationshipPathJoinClause({ - schemaProvider, - path: joinPath, - }); + const { joins, bindings: joinBindings, selectors } = await resolveJoin(schemaProvider, joinPath); + const classSelectors = selectors.flatMap((s) => [s.relationshipClassId, s.targetClassId]); const crossJoins = joins.replaceAll(/\bINNER\s+JOIN\b/gi, "CROSS JOIN"); const targetFilter = buildTargetFilter(target); const whereClause = targetFilter.where ? `WHERE ${targetFilter.where}` : ""; const allBindings = { ...joinBindings, ...targetFilter.bindings }; const ecsql = ` - SELECT GROUP_CONCAT(DISTINCT ec_classname([this].[ECClassId], 's.c')), ${buildClassNameColumns(joinPath)} + SELECT GROUP_CONCAT(DISTINCT ec_classname([this].[ECClassId], 's.c')), ${buildClassNameColumns(classSelectors)} FROM ${ECSql.createClassSelector(target.primaryClass)} [this] ${crossJoins} ${targetFilter.joins ?? ""} ${whereClause} - GROUP BY ${buildClassIdColumns(joinPath)} + GROUP BY ${buildClassIdColumns(classSelectors)} `; return { ecsql, ...(Object.keys(allBindings).length > 0 ? { bindings: allBindings } : {}) }; }, @@ -263,16 +285,26 @@ async function resolveDeclarationPaths({ const rows = raceQueryExecution({ executor: imodelAccess, queries }); return lastValueFrom( rows.pipe( - // Each row is one resolved path: [nearEndClasses, step0Target, step1Target, ...]. The concrete - // content-target (near-end) classes are pre-aggregated by the query via `GROUP_CONCAT`. - map((row) => ({ - path: declaration.path.map((step: RelationshipPath[number], i: number) => ({ - ...step, - sourceClassName: (i === 0 ? target.primaryClass : row[i]) as EC.FullClassNameDotNotation, - targetClassName: row[i + 1] as EC.FullClassNameDotNotation, - })), - targetClassNames: toSortedUniqueClassNames((row[0] as string).split(",") as EC.FullClassNameDotNotation[]), - })), + // Each row is one resolved path: [nearEndClasses, step0Rel, step0Target, step1Rel, step1Target, ...]. + // The concrete content-target (near-end) classes are pre-aggregated by the + // query via `GROUP_CONCAT`. Step target and relationship classes are the concrete classes + // found in the data, resolved per step. + map((row) => { + let colIdx = 0; + const path = []; + for (const step of declaration.path) { + path.push({ + ...step, + sourceClassName: (colIdx === 0 ? target.primaryClass : row[colIdx]) as EC.FullClassNameDotNotation, + relationshipName: row[++colIdx] as EC.FullClassNameDotNotation, + targetClassName: row[++colIdx] as EC.FullClassNameDotNotation, + }); + } + return { + path, + targetClassNames: toSortedUniqueClassNames((row[0] as string).split(",") as EC.FullClassNameDotNotation[]), + }; + }), toArray(), ), ); diff --git a/packages/content/src/test/Content.test.ts b/packages/content/src/test/Content.test.ts index 41276e5d3..3c44d787b 100644 --- a/packages/content/src/test/Content.test.ts +++ b/packages/content/src/test/Content.test.ts @@ -19,21 +19,38 @@ import type { import type { ContentTarget, ResolvedPath } from "../content/ContentTarget.js"; import type { IModelFieldsProvider } from "../content/extensions/IModelFieldsProvider.js"; -// Mock `ECSql.createRelationshipPathJoinClause` because the real implementation requires -// a functioning ECSchemaProvider that returns actual schema metadata to construct JOIN clauses. -// Here we return a fixed JOIN string so the tests can verify strategy/racing/mapping logic -// without needing real schema objects. +// Mock `ECSql.createRelationshipPathJoinInfo` / `ECSql.createRelationshipPathJoinClause` because the +// real implementations require a functioning ECSchemaProvider that returns actual schema metadata to +// construct JOIN clauses. `createRelationshipPathJoinInfo` returns the per-step selectors; the sync +// `createRelationshipPathJoinClause` overload renders the fixed JOIN string from that info so the +// tests can verify strategy/racing/mapping logic without needing real schema objects. vi.mock("@itwin/presentation-shared", async (importOriginal) => { const actual: any = await importOriginal(); + const FIXED_JOINS = `INNER JOIN [TestSchema].[TestRel] [r0] ON [r0].[SourceECInstanceId] = [this].[ECInstanceId] INNER JOIN [TestSchema].[TestTarget] [s0] ON [s0].[ECInstanceId] = [r0].[TargetECInstanceId]`; return { ...actual, // eslint-disable-next-line @typescript-eslint/naming-convention ECSql: { ...actual.ECSql, - createRelationshipPathJoinClause: vi.fn(async () => ({ - joins: `INNER JOIN [TestSchema].[TestRel] [r0] ON [r0].[SourceECInstanceId] = [this].[ECInstanceId] INNER JOIN [TestSchema].[TestTarget] [s0] ON [s0].[ECInstanceId] = [r0].[TargetECInstanceId]`, + createRelationshipPathJoinInfo: async ({ + path, + }: { + path: Array<{ sourceAlias: string; targetAlias: string; relationshipAlias: string }>; + }) => ({ + // `joins` is a `RelationshipJoinInfo[]` in the real result; the tests never inspect it (the + // rendered string comes from the mocked `createRelationshipPathJoinClause` below). + steps: path.map((step) => ({ + joins: [], + sourceClassIdSelector: `[${step.sourceAlias}].[ECClassId]`, + relationshipClassIdSelector: `[${step.relationshipAlias}].[ECClassId]`, + targetClassIdSelector: `[${step.targetAlias}].[ECClassId]`, + })), bindings: undefined, - })), + }), + createRelationshipPathJoinClause: (info: { + steps: Array<{ relationshipClassIdSelector: string }>; + bindings?: unknown; + }) => ({ joins: FIXED_JOINS, bindings: info.bindings }), }, }; }); @@ -200,7 +217,11 @@ describe("resolveContentSources", () => { }, ]; const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); - const queryRow: ECSqlQueryRow = { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }; + const queryRow: ECSqlQueryRow = { + 0: "TestSchema.ClassA", + 1: "TestSchema.ConcreteRelAB", + 2: "TestSchema.ConcreteB", + }; const imodelAccess = createMockIModelAccess({ resolvePathsQueryResults: [queryRow] }); const result = await resolveContentSources({ @@ -223,7 +244,7 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteB", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRelAB", }, ], targetClassNames: ["TestSchema.ClassA"], @@ -246,8 +267,8 @@ describe("resolveContentSources", () => { const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); const imodelAccess = createMockIModelAccess({ resolvePathsQueryResults: [ - { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB1" }, - { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB2" }, + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB1", 2: "TestSchema.ConcreteB1" }, + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB2", 2: "TestSchema.ConcreteB2" }, ], }); @@ -271,7 +292,7 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteB1", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRelAB1", }, ], targetClassNames: ["TestSchema.ClassA"], @@ -281,7 +302,7 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteB2", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRelAB2", }, ], targetClassNames: ["TestSchema.ClassA"], @@ -294,6 +315,120 @@ describe("resolveContentSources", () => { }); }); + describe("concrete relationship class resolution", () => { + it("projects and groups by the concrete relationship class in the resolution query", async () => { + const path: RelationshipPath = [ + { + sourceClassName: "TestSchema.ClassA", + targetClassName: "TestSchema.ClassB", + relationshipName: "TestSchema.RelAB", + }, + ]; + const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); + const imodelAccess = createMockIModelAccess({ + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], + }); + + await resolveContentSources({ imodelAccess, targets: [targetA], config: { imodelFieldsProviders: [provider] } }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const allQueries = vi.mocked(imodelAccess.createQueryReader).mock.calls.map((c) => c[0].ecsql); + const queries = allQueries.filter((ecsql) => !isPrimaryEnumerationQuery(ecsql)); + expect(queries.length).to.be.greaterThan(0); + for (const ecsql of queries) { + // the concrete relationship class name is projected... + expect(ecsql).to.include("ec_classname([r0].[ECClassId], 's.c')"); + // ...and the query groups by the relationship class id so distinct subclasses are separate rows + const groupByClause = ecsql.slice(ecsql.lastIndexOf("GROUP BY")); + expect(groupByClause).to.include("[r0].[ECClassId]"); + } + }); + + it("resolves distinct relationship subclasses of the same source/target as separate paths", async () => { + const path: RelationshipPath = [ + { + sourceClassName: "TestSchema.ClassA", + targetClassName: "TestSchema.ClassB", + relationshipName: "TestSchema.BaseRel", + }, + ]; + const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); + // Same near-end and target classes, but two concrete relationship subclasses present in the data. + const imodelAccess = createMockIModelAccess({ + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRel1", 2: "TestSchema.ConcreteB" }, + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRel2", 2: "TestSchema.ConcreteB" }, + ], + }); + + const result = await resolveContentSources({ + imodelAccess, + targets: [targetA], + config: { imodelFieldsProviders: [provider] }, + }); + + expect(result[0].resolvedDeclarations[0].paths).to.deep.equal([ + { + path: [ + { + sourceClassName: "TestSchema.ClassA", + targetClassName: "TestSchema.ConcreteB", + relationshipName: "TestSchema.ConcreteRel1", + }, + ], + targetClassNames: ["TestSchema.ClassA"], + }, + { + path: [ + { + sourceClassName: "TestSchema.ClassA", + targetClassName: "TestSchema.ConcreteB", + relationshipName: "TestSchema.ConcreteRel2", + }, + ], + targetClassNames: ["TestSchema.ClassA"], + }, + ]); + }); + + it("projects the first step's relationship class out of the anchoring subquery for multi-step paths", async () => { + // A 2-step path makes the subquery-anchor strategy applicable; its first step's relationship + // class is resolved inside the anchoring subquery and projected out as `FirstStepRelClassId`. + const path: RelationshipPath = [ + { + sourceClassName: "TestSchema.ClassA", + targetClassName: "TestSchema.ClassB", + relationshipName: "TestSchema.RelAB", + }, + { + sourceClassName: "TestSchema.ClassB", + targetClassName: "TestSchema.ClassC", + relationshipName: "TestSchema.RelBC", + }, + ]; + const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); + const imodelAccess = createMockIModelAccess({ + resolvePathsQueryResults: [ + { + 0: "TestSchema.ClassA", + 1: "TestSchema.ConcreteRelAB", + 2: "TestSchema.ConcreteB", + 3: "TestSchema.ConcreteRelBC", + 4: "TestSchema.ConcreteC", + }, + ], + }); + + await resolveContentSources({ imodelAccess, targets: [targetA], config: { imodelFieldsProviders: [provider] } }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const queries = vi.mocked(imodelAccess.createQueryReader).mock.calls.map((c) => c[0].ecsql); + expect(queries.some((ecsql) => ecsql.includes("[FirstStepRelClassId]"))).to.equal(true); + }); + }); + describe("multi-step path resolution", () => { it("resolves a multi-step path with correct source/target from each row", async () => { const path: RelationshipPath = [ @@ -309,7 +444,13 @@ describe("resolveContentSources", () => { }, ]; const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); - const queryRow: ECSqlQueryRow = { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB", 2: "TestSchema.ConcreteC" }; + const queryRow: ECSqlQueryRow = { + 0: "TestSchema.ClassA", + 1: "TestSchema.ConcreteRelAB", + 2: "TestSchema.ConcreteB", + 3: "TestSchema.ConcreteRelBC", + 4: "TestSchema.ConcreteC", + }; const imodelAccess = createMockIModelAccess({ resolvePathsQueryResults: [queryRow] }); const result = await resolveContentSources({ @@ -332,12 +473,12 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteB", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRelAB", }, { sourceClassName: "TestSchema.ConcreteB", targetClassName: "TestSchema.ConcreteC", - relationshipName: "TestSchema.RelBC", + relationshipName: "TestSchema.ConcreteRelBC", }, ], targetClassNames: ["TestSchema.ClassA"], @@ -370,9 +511,12 @@ describe("resolveContentSources", () => { const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); const queryRow: ECSqlQueryRow = { 0: "TestSchema.ClassA", - 1: "TestSchema.ConcreteB", - 2: "TestSchema.ConcreteC", - 3: "TestSchema.ConcreteD", + 1: "TestSchema.ConcreteRelAB", + 2: "TestSchema.ConcreteB", + 3: "TestSchema.ConcreteRelBC", + 4: "TestSchema.ConcreteC", + 5: "TestSchema.ConcreteRelCD", + 6: "TestSchema.ConcreteD", }; const imodelAccess = createMockIModelAccess({ resolvePathsQueryResults: [queryRow] }); @@ -401,17 +545,17 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteB", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRelAB", }, { sourceClassName: "TestSchema.ConcreteB", targetClassName: "TestSchema.ConcreteC", - relationshipName: "TestSchema.RelBC", + relationshipName: "TestSchema.ConcreteRelBC", }, { sourceClassName: "TestSchema.ConcreteC", targetClassName: "TestSchema.ConcreteD", - relationshipName: "TestSchema.RelCD", + relationshipName: "TestSchema.ConcreteRelCD", }, ], targetClassNames: ["TestSchema.ClassA"], @@ -437,7 +581,9 @@ describe("resolveContentSources", () => { // The query aggregates the concrete near-end classes for a shared downstream target into a // single `GROUP_CONCAT`ed cell; the resolver splits and sorts them. const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.Sub2,TestSchema.Sub1", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.Sub2,TestSchema.Sub1", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); const result = await resolveContentSources({ @@ -456,7 +602,7 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteB", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRelAB", }, ], targetClassNames: ["TestSchema.Sub1", "TestSchema.Sub2"], @@ -477,8 +623,8 @@ describe("resolveContentSources", () => { const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); const imodelAccess = createMockIModelAccess({ resolvePathsQueryResults: [ - { 0: "TestSchema.Sub1", 1: "TestSchema.ConcreteB" }, - { 0: "TestSchema.Sub2", 1: "TestSchema.ConcreteC" }, + { 0: "TestSchema.Sub1", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + { 0: "TestSchema.Sub2", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteC" }, ], }); @@ -498,7 +644,7 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteB", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRelAB", }, ], targetClassNames: ["TestSchema.Sub1"], @@ -508,7 +654,7 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteC", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRelAB", }, ], targetClassNames: ["TestSchema.Sub2"], @@ -583,7 +729,9 @@ describe("resolveContentSources", () => { const provider1 = createMockIModelFieldsProvider("provider1_v1", { relatedProperties: [{ path: pathA }] }); const provider2 = createMockIModelFieldsProvider("provider2_v1", { relatedProperties: [{ path: pathB }] }); const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteTarget" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRel", 2: "TestSchema.ConcreteTarget" }, + ], }); const result = await resolveContentSources({ @@ -602,7 +750,7 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteTarget", - relationshipName: "TestSchema.RelAB", + relationshipName: "TestSchema.ConcreteRel", }, ], targetClassNames: ["TestSchema.ClassA"], @@ -618,7 +766,7 @@ describe("resolveContentSources", () => { { sourceClassName: "TestSchema.ClassA", targetClassName: "TestSchema.ConcreteTarget", - relationshipName: "TestSchema.RelAC", + relationshipName: "TestSchema.ConcreteRel", }, ], targetClassNames: ["TestSchema.ClassA"], @@ -639,7 +787,9 @@ describe("resolveContentSources", () => { const provider1 = createMockIModelFieldsProvider("skipped_v1", undefined); const provider2 = createMockIModelFieldsProvider("active_v1", { relatedProperties: [{ path }] }); const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); const result = await resolveContentSources({ @@ -669,7 +819,9 @@ describe("resolveContentSources", () => { }; const provider2 = createMockIModelFieldsProvider("fast_v1", { relatedProperties: [{ path }] }); const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); const result = await resolveContentSources({ @@ -694,7 +846,9 @@ describe("resolveContentSources", () => { const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); const target: ContentTarget = { primaryClass: "TestSchema.ClassA", instanceIds: ["0x1", "0x2"] }; const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); await resolveContentSources({ imodelAccess, targets: [target], config: { imodelFieldsProviders: [provider] } }); @@ -727,7 +881,9 @@ describe("resolveContentSources", () => { instanceFilter: { expression: "this.Area > :minArea", bindings: { minArea: { type: "double", value: 100.0 } } }, }; const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); await resolveContentSources({ imodelAccess, targets: [target], config: { imodelFieldsProviders: [provider] } }); @@ -755,7 +911,9 @@ describe("resolveContentSources", () => { instanceFilter: { expression: 'x.Name = "test"', primaryClassAlias: "x" }, }; const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); await resolveContentSources({ imodelAccess, targets: [target], config: { imodelFieldsProviders: [provider] } }); @@ -781,7 +939,9 @@ describe("resolveContentSources", () => { instanceFilter: { expression: '[x].Name = "test"', primaryClassAlias: "x" }, }; const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); await resolveContentSources({ imodelAccess, targets: [target], config: { imodelFieldsProviders: [provider] } }); @@ -820,7 +980,17 @@ describe("resolveContentSources", () => { }, }; const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ConcreteB", 1: "TestSchema.ConcreteC", 2: "TestSchema.ConcreteD" }], + resolvePathsQueryResults: [ + { + 0: "TestSchema.ClassA", + 1: "TestSchema.ConcreteRelAB", + 2: "TestSchema.ConcreteB", + 3: "TestSchema.ConcreteRelBC", + 4: "TestSchema.ConcreteC", + 5: "TestSchema.ConcreteRelCD", + 6: "TestSchema.ConcreteD", + }, + ], }); await resolveContentSources({ imodelAccess, targets: [target], config: { imodelFieldsProviders: [provider] } }); @@ -848,7 +1018,9 @@ describe("resolveContentSources", () => { const target1: ContentTarget = { primaryClass: "TestSchema.ClassA" }; const target2: ContentTarget = { primaryClass: "TestSchema.ClassD" }; const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); const result = await resolveContentSources({ @@ -874,7 +1046,9 @@ describe("resolveContentSources", () => { const provider = createMockIModelFieldsProvider("test_v1", { relatedProperties: [{ path }] }); const targets: ContentTarget[] = [{ primaryClass: "TestSchema.ClassA" }, { primaryClass: "TestSchema.ClassB" }]; const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteB" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteB" }, + ], }); await resolveContentSources({ imodelAccess, targets, config: { imodelFieldsProviders: [provider] } }); @@ -904,7 +1078,9 @@ describe("resolveContentSources", () => { relatedProperties: [{ path: pathA }, { path: pathB }], }); const imodelAccess = createMockIModelAccess({ - resolvePathsQueryResults: [{ 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteTarget" }], + resolvePathsQueryResults: [ + { 0: "TestSchema.ClassA", 1: "TestSchema.ConcreteRelAB", 2: "TestSchema.ConcreteTarget" }, + ], }); const result = await resolveContentSources({ diff --git a/packages/shared/api/presentation-shared.api.md b/packages/shared/api/presentation-shared.api.md index 823122fac..685bd982a 100644 --- a/packages/shared/api/presentation-shared.api.md +++ b/packages/shared/api/presentation-shared.api.md @@ -674,7 +674,7 @@ interface RelationshipPathJoinClauseResult { // @public interface RelationshipPathJoinInfo { bindings?: Record; - joins: RelationshipJoinInfo[]; + steps: RelationshipPathStepJoinInfo[]; } // @public @@ -691,6 +691,14 @@ interface RelationshipPathStep { targetClassName: EC.FullClassNameDotNotation; } +// @public +interface RelationshipPathStepJoinInfo { + joins: RelationshipJoinInfo[]; + relationshipClassIdSelector: string; + sourceClassIdSelector: string; + targetClassIdSelector: string; +} + // @public export function releaseMainThread(): Promise; diff --git a/packages/shared/src/shared/ecsql-snippets/ECSqlJoinSnippets.ts b/packages/shared/src/shared/ecsql-snippets/ECSqlJoinSnippets.ts index 8bc63fd69..dcbc491d4 100644 --- a/packages/shared/src/shared/ecsql-snippets/ECSqlJoinSnippets.ts +++ b/packages/shared/src/shared/ecsql-snippets/ECSqlJoinSnippets.ts @@ -75,12 +75,10 @@ interface RelationshipJoinInfo { } /** - * A resolved, render-ready description of a relationship-path JOIN, as a flat, ordered list of - * concrete join clauses. Produced by `createRelationshipPathJoinInfo`; consumed by the - * `createRelationshipPathJoinClause` render overload. + * Per-path-step resolution info that is not tied to a specific JOIN clause. * @public */ -interface RelationshipPathJoinInfo { +interface RelationshipPathStepJoinInfo { /** * The ordered JOIN clauses to emit — one entry per JOIN. A path step contributes 1 (navigation * property) or 2 (link-table) entries. Note the join-table count is not `joins.length`: an @@ -88,6 +86,42 @@ interface RelationshipPathJoinInfo { * relationship + target. */ joins: RelationshipJoinInfo[]; + /** + * An ECSQL selector that yields the step's concrete relationship `ECClassId` for the actual data + * row being traversed. The concept is the same for every step; only the expression differs by how + * the relationship is represented: + * - link-table step: `[relationship_alias].[ECClassId]` (the joined relationship table), + * - navigation-property step: `[owner_alias].[navigation_property_name].[RelECClassId]`, where the + * owner alias is the source or target of the step depending on the navigation property direction. + * + * Note: for `outer` joins the relationship row / navigation value may be `NULL` when nothing is + * related, in which case this selector yields `NULL`. + */ + relationshipClassIdSelector: string; + /** + * An ECSQL selector that yields the step's concrete source `ECClassId` for the actual data row + * being traversed, i.e. `[source_alias].[ECClassId]` of the class the step is joined from. + */ + sourceClassIdSelector: string; + /** + * An ECSQL selector that yields the step's concrete target `ECClassId` for the actual data row + * being traversed, i.e. `[target_alias].[ECClassId]` of the class the step is joined to. + * + * Note: for `outer` joins the target row may be `NULL` when nothing is related, in which case this + * selector yields `NULL`. + */ + targetClassIdSelector: string; +} + +/** + * A resolved, render-ready description of a relationship-path JOIN, as a flat, ordered list of + * concrete join clauses. Produced by `createRelationshipPathJoinInfo`; consumed by the + * `createRelationshipPathJoinClause` render overload. + * @public + */ +interface RelationshipPathJoinInfo { + /** Per-path-step resolution info, one entry per input path step, in path order. */ + steps: RelationshipPathStepJoinInfo[]; /** `instanceFilter` bindings collected across all steps, or `undefined` when none. */ bindings?: Record; } @@ -100,19 +134,23 @@ export async function createRelationshipPathJoinInfo( props: CreateRelationshipPathJoinClauseProps, ): Promise { if (props.path.length === 0) { - return { joins: [] }; + return { steps: [] }; } let prev = { alias: props.path[0].sourceAlias, joinPropertyName: "ECInstanceId", className: props.path[0].sourceClassName, }; - const joins: RelationshipJoinInfo[] = []; + const steps: RelationshipPathStepJoinInfo[] = []; const bindings: Record = {}; for (const stepDef of props.path) { const step = await getRelationshipPathStepClasses(props.schemaProvider, stepDef); const navigationProperty = await getNavigationProperty(step); const filterCondition = resolveInstanceFilterCondition(step); + // Source/target `ECClassId` selectors are resolved the same way regardless of how the + // relationship is represented: read from the source/target aliases actually joined for this step. + const sourceClassIdSelector = createRawPropertyValueSelector(prev.alias, "ECClassId"); + const targetClassIdSelector = createRawPropertyValueSelector(step.targetAlias, "ECClassId"); if (step.instanceFilter?.bindings) { for (const [key, value] of Object.entries(step.instanceFilter.bindings)) { if (key in bindings) { @@ -125,17 +163,34 @@ export async function createRelationshipPathJoinInfo( } if (navigationProperty) { const isNavigationPropertyForward = navigationProperty.direction === "Forward"; + // The navigation value (`.Id` and `.RelECClassId`) lives on the same alias that holds the + // navigation property in the join condition below. + const navigationValueAlias = + isNavigationPropertyForward === !step.relationshipReverse ? prev.alias : step.targetAlias; + const joinCondition = isNavigationPropertyForward === !step.relationshipReverse ? `${createRawPropertyValueSelector(step.targetAlias, "ECInstanceId")} = ${createRawPropertyValueSelector(prev.alias, navigationProperty.name, "Id")}${filterCondition}` : `${createRawPropertyValueSelector(step.targetAlias, navigationProperty.name, "Id")} = ${createRawPropertyValueSelector(prev.alias, prev.joinPropertyName)}${filterCondition}`; - joins.push({ - joinType: step.joinType ?? "inner", - joinTarget: { kind: "class", className: step.target.fullName }, - joinAlias: step.targetAlias, - joinCondition, + steps.push({ + relationshipClassIdSelector: createRawPropertyValueSelector( + navigationValueAlias, + navigationProperty.name, + "RelECClassId", + ), + sourceClassIdSelector, + targetClassIdSelector, + joins: [ + { + joinType: step.joinType ?? "inner", + joinTarget: { kind: "class", className: step.target.fullName }, + joinAlias: step.targetAlias, + joinCondition, + }, + ], }); } else { + const joins: RelationshipJoinInfo[] = []; const relPropNames = !step.relationshipReverse ? { this: "SourceECInstanceId", next: "TargetECInstanceId" } : { this: "TargetECInstanceId", next: "SourceECInstanceId" }; @@ -168,10 +223,17 @@ export async function createRelationshipPathJoinInfo( joinAlias: step.targetAlias, joinCondition: targetJoinCondition, }); + + steps.push({ + relationshipClassIdSelector: createRawPropertyValueSelector(step.relationshipAlias, "ECClassId"), + sourceClassIdSelector, + targetClassIdSelector, + joins, + }); } prev = { alias: step.targetAlias, className: step.target.fullName, joinPropertyName: "ECInstanceId" }; } - return { joins, bindings: Object.keys(bindings).length > 0 ? bindings : undefined }; + return { steps, bindings: Object.keys(bindings).length > 0 ? bindings : undefined }; } /** @@ -229,7 +291,7 @@ export function createRelationshipPathJoinClause(info: RelationshipPathJoinInfo) export function createRelationshipPathJoinClause( arg: CreateRelationshipPathJoinClauseProps | RelationshipPathJoinInfo, ): Promise | RelationshipPathJoinClauseResult { - if ("joins" in arg) { + if ("steps" in arg) { return renderRelationshipPathJoinClause(arg); } return createRelationshipPathJoinInfo(arg).then(renderRelationshipPathJoinClause); @@ -237,7 +299,8 @@ export function createRelationshipPathJoinClause( function renderRelationshipPathJoinClause(info: RelationshipPathJoinInfo): RelationshipPathJoinClauseResult { let joins = ""; - for (const entry of info.joins) { + const flatJoins = info.steps.flatMap((step) => step.joins); + for (const entry of flatJoins) { const joinKw = entry.joinType === "outer" ? "OUTER JOIN" : "INNER JOIN"; if (entry.joinTarget.kind === "class") { joins += ` diff --git a/packages/shared/src/test/ecsql-snippets/ECSqlJoinSnippets.test.ts b/packages/shared/src/test/ecsql-snippets/ECSqlJoinSnippets.test.ts index 789c6fa8c..8ff22ab7f 100644 --- a/packages/shared/src/test/ecsql-snippets/ECSqlJoinSnippets.test.ts +++ b/packages/shared/src/test/ecsql-snippets/ECSqlJoinSnippets.test.ts @@ -710,7 +710,7 @@ describe("createRelationshipPathJoinClause", () => { describe("createRelationshipPathJoinInfo", () => { it("returns empty joins array for empty path", async () => { const result = await createRelationshipPathJoinInfo({ schemaProvider, path: [] }); - expect(result.joins).toEqual([]); + expect(result.steps).toEqual([]); expect(result.bindings).toBeUndefined(); }); @@ -736,11 +736,17 @@ describe("createRelationshipPathJoinClause", () => { }, ], }); - expect(result.joins).toHaveLength(1); - expect(result.joins[0].joinType).toBe("inner"); - expect(result.joins[0].joinTarget).toEqual({ kind: "class", className: targetClass.fullName }); - expect(result.joins[0].joinAlias).toBe("t"); - expect(trimWhitespace(result.joins[0].joinCondition)).toBe( + expect(result.steps).toHaveLength(1); + expect(result.steps[0].relationshipClassIdSelector).toBe(`[s].[${navigationProperty.name}].[RelECClassId]`); + expect(result.steps[0].sourceClassIdSelector).toBe("[s].[ECClassId]"); + expect(result.steps[0].targetClassIdSelector).toBe("[t].[ECClassId]"); + + const joins = result.steps[0].joins; + expect(joins).toHaveLength(1); + expect(joins[0].joinType).toBe("inner"); + expect(joins[0].joinTarget).toEqual({ kind: "class", className: targetClass.fullName }); + expect(joins[0].joinAlias).toBe("t"); + expect(trimWhitespace(joins[0].joinCondition)).toBe( trimWhitespace(`[t].[ECInstanceId] = [s].[${navigationProperty.name}].[Id]`), ); expect(result.bindings).toBeUndefined(); @@ -761,19 +767,25 @@ describe("createRelationshipPathJoinClause", () => { }, ], }); - expect(result.joins).toHaveLength(2); - expect(result.joins[0]).toMatchObject({ + expect(result.steps).toHaveLength(1); + expect(result.steps[0].relationshipClassIdSelector).toBe("[r].[ECClassId]"); + expect(result.steps[0].sourceClassIdSelector).toBe("[s].[ECClassId]"); + expect(result.steps[0].targetClassIdSelector).toBe("[t].[ECClassId]"); + const joins = result.steps[0].joins; + expect(joins).toHaveLength(2); + expect(joins[0]).toMatchObject({ joinType: "inner", joinTarget: { kind: "class", className: relationship.fullName }, joinAlias: "r", }); - expect(trimWhitespace(result.joins[0].joinCondition)).toBe("[r].[SourceECInstanceId] = [s].[ECInstanceId]"); - expect(result.joins[1]).toMatchObject({ + expect(trimWhitespace(joins[0].joinCondition)).toBe("[r].[SourceECInstanceId] = [s].[ECInstanceId]"); + expect(joins[1]).toMatchObject({ joinType: "inner", joinTarget: { kind: "class", className: targetClass.fullName }, joinAlias: "t", }); - expect(trimWhitespace(result.joins[1].joinCondition)).toBe("[t].[ECInstanceId] = [r].[TargetECInstanceId]"); + expect(trimWhitespace(joins[1].joinCondition)).toBe("[t].[ECInstanceId] = [r].[TargetECInstanceId]"); + expect(result.bindings).toBeUndefined(); }); @@ -793,8 +805,14 @@ describe("createRelationshipPathJoinClause", () => { }, ], }); - expect(result.joins).toHaveLength(2); - const first = result.joins[0]; + + expect(result.steps).toHaveLength(1); + expect(result.steps[0].relationshipClassIdSelector).toBe("[r].[ECClassId]"); + expect(result.steps[0].sourceClassIdSelector).toBe("[s].[ECClassId]"); + expect(result.steps[0].targetClassIdSelector).toBe("[t].[ECClassId]"); + const joins = result.steps[0].joins; + expect(joins).toHaveLength(2); + const first = joins[0]; expect(first.joinType).toBe("outer"); expect(first.joinAlias).toBe("r"); assert(first.joinTarget.kind === "relationship-select"); @@ -805,12 +823,12 @@ describe("createRelationshipPathJoinClause", () => { expect(joinTarget.innerTargetAlias).toBe("t"); expect(trimWhitespace(joinTarget.innerJoinCondition)).toBe("[t].[ECInstanceId] = [r].[TargetECInstanceId]"); expect(trimWhitespace(first.joinCondition)).toBe("[r].[SourceECInstanceId] = [s].[ECInstanceId]"); - expect(result.joins[1]).toMatchObject({ + expect(joins[1]).toMatchObject({ joinType: "outer", joinTarget: { kind: "class", className: targetClass.fullName }, joinAlias: "t", }); - expect(trimWhitespace(result.joins[1].joinCondition)).toBe("[t].[ECInstanceId] = [r].[TargetECInstanceId]"); + expect(trimWhitespace(joins[1].joinCondition)).toBe("[t].[ECInstanceId] = [r].[TargetECInstanceId]"); expect(result.bindings).toBeUndefined(); }); @@ -846,6 +864,15 @@ describe("createRelationshipPathJoinClause", () => { }, ], }); + expect(result.steps).toHaveLength(2); + expect(result.steps[0].relationshipClassIdSelector).toBe(`[r1].[ECClassId]`); + expect(result.steps[0].sourceClassIdSelector).toBe(`[a].[ECClassId]`); + expect(result.steps[0].targetClassIdSelector).toBe(`[b].[ECClassId]`); + + expect(result.steps[1].relationshipClassIdSelector).toBe(`[r2].[ECClassId]`); + expect(result.steps[1].sourceClassIdSelector).toBe(`[b].[ECClassId]`); + expect(result.steps[1].targetClassIdSelector).toBe(`[c].[ECClassId]`); + expect(result.bindings).toEqual({ isActive: { type: "boolean", value: true }, minWeight: { type: "double", value: 5.0 },