Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/yummy-lands-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@itwin/presentation-shared": minor
Comment thread
grigasp marked this conversation as resolved.
---

`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.
5 changes: 5 additions & 0 deletions packages/content/src/content/ContentTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
110 changes: 71 additions & 39 deletions packages/content/src/content/ResolveContentSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ECSqlBinding>;
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
Expand Down Expand Up @@ -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 } : {}) };
},
Expand All @@ -149,37 +167,43 @@ 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
const firstStep = joinPath[0];
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 };
Expand All @@ -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 } : {}) };
},
Expand Down Expand Up @@ -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(),
),
);
Expand Down
Loading