@@ -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 }
0 commit comments