There are 2 edge cases that are not handled with current base query building logic introduced by #1462:
- When filter path contains one to many path it might return more rows when anchor is related to multiple instances:
it("[gap 1] builds an anchor query that may return the same primary once per matching related instance", async () => {
const path = [makeStep(primaryClass, "TestSchema.RelMany", "TestSchema.Many")];
const field = makePropertyField({
propertyName: "Name",
propertyClassName: "TestSchema.Many",
pathFromTarget: path,
valueClassNames: ["TestSchema.Many"],
});
const result = await buildBaseQuery({
schemaProvider,
source: makeSource([path]),
includeRelatedJoins: true,
filters: [{ field, operator: "is-equal", value: "A" }],
});
const anchorQuery = `
SELECT [this].[ECInstanceId]
${result.anchor.parts.from}
${result.anchor.parts.joins}
WHERE ${result.anchor.parts.where}
`;
expect(trimWhitespace(anchorQuery)).to.equal(
trimWhitespace(`
SELECT [this].[ECInstanceId]
FROM [TestSchema].[Primary] [this]
OUTER JOIN (
SELECT [${ECSQL_PREFIX}r0].*
FROM [TestSchema].[RelMany] [${ECSQL_PREFIX}r0]
INNER JOIN [TestSchema].[Many] [${ECSQL_PREFIX}t0] ON [${ECSQL_PREFIX}t0].[ECInstanceId] = [${ECSQL_PREFIX}r0].[TargetECInstanceId]
) [${ECSQL_PREFIX}r0] ON [${ECSQL_PREFIX}r0].[SourceECInstanceId] = [this].[ECInstanceId]
OUTER JOIN [TestSchema].[Many] [${ECSQL_PREFIX}t0] ON [${ECSQL_PREFIX}t0].[ECInstanceId] = [${ECSQL_PREFIX}r0].[TargetECInstanceId]
WHERE [${ECSQL_PREFIX}t0].[Name] = :${ECSQL_PREFIX}vf0
`),
);
// For example, assume TestSchema.Primary(0x1) is related through TestSchema.RelMany to both
// TestSchema.Many(0x2, Name = "A") and TestSchema.Many(0x3, Name = "A"). The relationship JOIN
// produces these rows before projection:
// this.ECInstanceId | r0.TargetECInstanceId | t0.Name
// 0x1 | 0x2 | A
// 0x1 | 0x3 | A
// Both rows pass the Name filter, so SELECT [this].[ECInstanceId] returns [0x1, 0x1].
// An existential or de-duplicated subquery would keep
// the anchor at one row per TestSchema.Primary instance.
});
- When filter path + anchor exceeds 64 JOIN limit:
it("[gap 2] keeps filter-required joins within SQLite's table limit", async () => {
const paths = Array.from({ length: 32 }, (_, index) => [
makeStep(primaryClass, `TestSchema.Rel${index}`, `TestSchema.Target${index}`),
]);
const filters: ContentValueFilter[] = paths.map((path, index) => ({
field: makePropertyField({
propertyName: "Name",
propertyClassName: `TestSchema.Target${index}`,
pathFromTarget: path,
valueClassNames: [`TestSchema.Target${index}`],
}),
operator: "is-equal",
value: "A",
}));
const result = await buildBaseQuery({
schemaProvider,
source: makeSource(paths),
includeRelatedJoins: false,
filters,
});
// Filter-required paths are added directly to the anchor after related paths are partitioned by
// the join budget. Each link-table path adds two top-level OUTER JOINs: one derived relationship
// table and one target table. With the primary FROM table, 32 paths therefore use 65 tables and
// exceed SQLite's 64-table limit instead of being split into separate query groups.
const outerJoinCount = result.anchor.parts.joins.match(/\bOUTER JOIN\b/g)?.length ?? 0;
expect(1 + outerJoinCount).to.be.at.most(64);
});
There are 2 edge cases that are not handled with current base query building logic introduced by #1462: