Skip to content

Commit 399fe8e

Browse files
committed
Use pivot
1 parent 6690313 commit 399fe8e

3 files changed

Lines changed: 110 additions & 31 deletions

File tree

packages/core/src/database/repositories/taxonomy.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,14 @@ export class TaxonomyRepository {
486486
*
487487
* Returns a Map from translation_group to distinct-entry count. Counts are
488488
* global across collections, mirroring `countEntriesForTerms`.
489+
*
490+
* Shares the translation-group `parent_id` walk primitive with the `subtree`
491+
* where-filter (`subtreeGroupsCteBody` in `loader.ts`) but is a deliberately
492+
* distinct query: it seeds from every term and keeps `(ancestor, descendant)`
493+
* lineage to roll up all terms in one pass, where the filter seeds from
494+
* specific roots and needs only the flat descendant set. Unifying the bodies
495+
* would pessimize the filter (full closure where a narrow walk suffices), so
496+
* only the walk is shared, not the query.
489497
*/
490498
async countEntriesForSubtrees(taxonomyName: string): Promise<Map<string, number>> {
491499
const result = await sql<{ grp: string; count: number | string }>`

packages/core/src/loader.ts

Lines changed: 77 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -555,6 +555,34 @@ function isWhereRange(value: WhereValue): value is WhereRange {
555555
);
556556
}
557557

558+
/**
559+
* Recursive CTE body that resolves the set of taxonomy groups in a subtree:
560+
* the given root slugs (within one taxonomy `name`) plus every descendant,
561+
* walked down `taxonomies.parent_id` in translation-group space
562+
* (`COALESCE(translation_group, id)`, valid after migration 045). Only the root
563+
* slugs are bound, so the parameter count is independent of subtree size — this
564+
* is what avoids D1's 100-bind-parameter overflow.
565+
*
566+
* `selfAlias` must be the enclosing CTE's own name; the recursive term joins
567+
* back to it. Shares the walk primitive with `countEntriesForSubtrees` in
568+
* `taxonomy.ts`, which instead seeds from every term and keeps ancestor lineage
569+
* to roll up all terms in one pass — a deliberately distinct query shape (see
570+
* that method's doc comment), so only the walk is shared, not the query body.
571+
*/
572+
function subtreeGroupsCteBody(
573+
name: string,
574+
roots: string[],
575+
selfAlias: ReturnType<typeof sql.ref>,
576+
): ReturnType<typeof sql> {
577+
return sql`
578+
SELECT COALESCE(translation_group, id) FROM taxonomies
579+
WHERE name = ${name} AND slug IN (${sql.join(roots.map((s) => sql`${s}`))})
580+
UNION
581+
SELECT COALESCE(c.translation_group, c.id) FROM taxonomies c
582+
JOIN ${selfAlias} ON c.parent_id = ${selfAlias}.grp
583+
`;
584+
}
585+
558586
/**
559587
* Build AND conditions for non-taxonomy field filters.
560588
* Returns an array of sql fragments; empty if no field filters apply.
@@ -928,36 +956,54 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
928956
)}`
929957
: sql``;
930958

931-
// Subtree filters: match content_taxonomies.taxonomy_id (already a
932-
// translation_group) against the set of groups in each root's subtree,
933-
// resolved by a recursive walk of taxonomies.parent_id (also a
934-
// translation_group after migration 045). Only the root slugs are bound,
935-
// so the parameter count is independent of subtree size — this is what
936-
// avoids D1's 100-bind-parameter overflow.
937-
const subtreeCond =
938-
subtreeFilters.length > 0
939-
? sql`${sql.join(
940-
subtreeFilters.map(
941-
(f) => sql`AND EXISTS (
942-
SELECT 1 FROM content_taxonomies ct
943-
WHERE ct.collection = ${type}
944-
AND ct.entry_id = ${sql.ref(tableName)}.id
945-
AND ct.taxonomy_id IN (
946-
WITH RECURSIVE sub(grp) AS (
947-
SELECT COALESCE(translation_group, id) FROM taxonomies
948-
WHERE name = ${f.name}
949-
AND slug IN (${sql.join(f.roots.map((s) => sql`${s}`))})
950-
UNION
951-
SELECT COALESCE(c.translation_group, c.id) FROM taxonomies c
952-
JOIN sub ON c.parent_id = sub.grp
953-
)
954-
SELECT grp FROM sub
955-
)
956-
)`,
957-
),
958-
sql` `,
959-
)}`
959+
// Subtree filters use a *pivot-driven* plan rather than a correlated
960+
// EXISTS. Each filter contributes a recursive `sub_n` CTE (the
961+
// translation-group subtree walk) and a `matched_n` CTE of DISTINCT
962+
// entry_ids tagged anywhere under that subtree, then JOINs `matched_n`
963+
// to the collection. This resolves matching entries from
964+
// content_taxonomies first — driven by idx_content_taxonomies_term,
965+
// reading only the taggings under the subtree — instead of walking the
966+
// collection in sort order and probing per candidate. The EXISTS plan
967+
// was entry-driven and degraded toward O(table) reads when the selected
968+
// subtree is sparse against the recency sort, which is exactly the
969+
// faceted-browse case this operator exists for.
970+
//
971+
// Multiple subtree taxonomies AND together as successive JOINs (set
972+
// intersection). `matched_n` is DISTINCT on entry_id (a text id) so each
973+
// JOIN is 1:1 — no row fan-out, no SELECT DISTINCT, so the Postgres
974+
// json-column DISTINCT hazard (which is why sibling filters use EXISTS)
975+
// never arises here. Always pivot-driven: a near-root selection covering
976+
// most rows is mildly suboptimal versus the old early-exit, but bounded,
977+
// and rare next to the sparse case the EXISTS plan made pathological.
978+
const subtreeCteParts: ReturnType<typeof sql>[] = [];
979+
const subtreeJoinParts: ReturnType<typeof sql>[] = [];
980+
subtreeFilters.forEach((f, i) => {
981+
const subAlias = sql.ref(`sub_${i}`);
982+
const matchedAlias = sql.ref(`matched_${i}`);
983+
subtreeCteParts.push(
984+
sql`${subAlias}(grp) AS (${subtreeGroupsCteBody(f.name, f.roots, subAlias)})`,
985+
);
986+
subtreeCteParts.push(
987+
sql`${matchedAlias}(entry_id) AS (
988+
SELECT DISTINCT entry_id FROM content_taxonomies
989+
WHERE collection = ${type}
990+
AND taxonomy_id IN (SELECT grp FROM ${subAlias})
991+
)`,
992+
);
993+
subtreeJoinParts.push(
994+
sql`JOIN ${matchedAlias} ON ${matchedAlias}.entry_id = ${sql.ref(tableName)}.id`,
995+
);
996+
});
997+
// `WITH RECURSIVE` covers the whole list; the non-recursive `matched_n`
998+
// CTEs are permitted alongside the recursive `sub_n` ones on both
999+
// dialects. Emitted only when a subtree filter is present, so every
1000+
// other query path is unchanged.
1001+
const withClause =
1002+
subtreeCteParts.length > 0
1003+
? sql`WITH RECURSIVE ${sql.join(subtreeCteParts, sql`, `)} `
9601004
: sql``;
1005+
const subtreeJoins =
1006+
subtreeJoinParts.length > 0 ? sql`${sql.join(subtreeJoinParts, sql` `)}` : sql``;
9611007

9621008
// `_emdash_content_bylines.byline_id` stores the byline's
9631009
// translation_group (migration 040), so a credit spans every
@@ -992,13 +1038,13 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
9921038
: sql`LIMIT -1 OFFSET ${offset}`;
9931039
}
9941040
result = await sql<Record<string, unknown>>`
995-
SELECT *, ${termsSelect}, ${bylinesSelect} FROM ${sql.ref(tableName)}
1041+
${withClause}SELECT ${sql.ref(tableName)}.*, ${termsSelect}, ${bylinesSelect} FROM ${sql.ref(tableName)}
1042+
${subtreeJoins}
9961043
WHERE deleted_at IS NULL
9971044
AND ${statusCondition}
9981045
${localeFilter}
9991046
${cursorCond}
10001047
${taxonomyCond}
1001-
${subtreeCond}
10021048
${bylineCond}
10031049
${fieldCondsSQL ? sql`AND ${fieldCondsSQL}` : sql``}
10041050
${orderByClause}

packages/core/tests/unit/loader-taxonomy-subtree-filter.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,31 @@ describeEachDialect("Loader taxonomy subtree filter", (dialectName: DialectName)
166166
expect(result.entries.map((e) => e.data.title)).toEqual(["north + featured"]);
167167
});
168168

169+
it("intersects two subtree filters across taxonomies (AND)", async () => {
170+
// category subtree: region -> north
171+
const region = await term("category", "region");
172+
const north = await term("category", "north", region);
173+
// tag subtree: topics -> sports
174+
const topics = await term("tag", "topics");
175+
const sports = await term("tag", "sports", topics);
176+
177+
const both = await createPost("north + sports");
178+
const northOnly = await createPost("north only");
179+
const sportsOnly = await createPost("sports only");
180+
await tag(both.id, north);
181+
await tag(both.id, sports);
182+
await tag(northOnly.id, north);
183+
await tag(sportsOnly.id, sports);
184+
185+
// An entry must match a descendant in *every* requested subtree taxonomy:
186+
// the two matched sets intersect to just the doubly-tagged entry.
187+
const result = await load({
188+
category: { subtree: "region" },
189+
tag: { subtree: "topics" },
190+
});
191+
expect(result.entries.map((e) => e.data.title)).toEqual(["north + sports"]);
192+
});
193+
169194
it("an empty subtree roots array matches nothing", async () => {
170195
const region = await term("category", "region");
171196
const post = await createPost("anything");

0 commit comments

Comments
 (0)