Skip to content

Add subtree (descendant) matching to taxonomy where filters - #1648

Closed
MA2153 wants to merge 19 commits into
emdash-cms:mainfrom
MA2153:feat/taxonomy-subtree-where-filter
Closed

Add subtree (descendant) matching to taxonomy where filters#1648
MA2153 wants to merge 19 commits into
emdash-cms:mainfrom
MA2153:feat/taxonomy-subtree-where-filter

Conversation

@MA2153

@MA2153 MA2153 commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a subtree operator to collection where taxonomy filters so that selecting a parent term matches that term and all of its descendants, resolved in SQL:

where: { region: { subtree: "europe" } } // matches europe + every descendant region

Design and rationale: Discussion #1647.

Motivation

The taxonomy where filter currently matches by exact slug only, so "this term or anything filed under it" — choosing a parent category in a faceted browse UI — is inexpressible. The only workaround is to enumerate the subtree client-side and pass every descendant slug, which expands to one bound parameter per slug and overflows D1's 100-bind-parameter cap (D1_ERROR: too many SQL variables) on deep hierarchies. It also can't be chunked without breaking keyset pagination.

Approach

  • where: { <taxonomy>: { subtree: slug | slug[] } } — a new WhereSubtree operator added to the WhereValue union. The loader resolves the subtree server-side from the root slug(s) via a WITH RECURSIVE walk of taxonomies.parent_id, matching content_taxonomies.taxonomy_id through an EXISTS clause. Only the root slugs are bound, so the parameter count is independent of subtree size. After fix: store translation_group in taxonomy parent_id to stop cross-locale parent leak #1646 both columns live in translation_group space, so the walk is locale-correct and needs no extra join.
  • Guarded and additive. The operator is inert unless subtree: is present. It is ignored (with a console.warn) on non-taxonomy keys and on byline; empty roots short-circuit to an empty result rather than emitting invalid SQL. The default exact-slug path is unchanged.
  • Opt-in rollup counts. getTaxonomyTerms(name, { rollup }) and the admin terms endpoint (?rollup=1) return distinct-entry subtree counts, so a facet badge equals what selecting that facet returns. Counts are computed with a single query and an in-memory fold up each term's ancestor chain (unioning entry ids to preserve DISTINCT), so the query count does not grow with the number of terms. Default counts (exact-term) are unchanged.
  • Portable. The recursive CTE is standard SQL on both dialects; all new tests run under describeEachDialect (SQLite locally, Postgres in CI).

Type of change

Checklist

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 4.8 (Claude Code)

Screenshots / test output

Dialect-parity tests run under describeEachDialect (SQLite locally; Postgres in CI via PG_CONNECTION_STRING).

  • loader-taxonomy-subtree-filter.test.ts (7) — single-root and multi-root union, a >999-descendant overflow guard (would exceed SQLite's bind limit if descendants were enumerated instead of walked in SQL), mixed exact + subtree across taxonomies, empty-roots short-circuit, cross-locale match by translation_group, and keyset pagination.
  • taxonomy-subtree-counts.test.ts (5) — distinct-entry rollup (an entry tagged at both a parent and its child counts once), shared-ancestor counting across sibling subtrees, empty-subtree handling, getTaxonomyTerms({ rollup }), and handleTermList({ rollup }).
  • schemas.test.ts?rollup query-param coercion (1/true/0/false).
 Test Files  2 passed (2)
      Tests  12 passed (12)

🤖 Generated with Claude Code

MA2153 and others added 9 commits June 29, 2026 12:35
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b49a2f2

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 16 packages
Name Type
emdash Minor
@emdash-cms/cloudflare Minor
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/admin Minor
@emdash-cms/auth Minor
@emdash-cms/blocks Minor
@emdash-cms/gutenberg-to-portable-text Minor
@emdash-cms/x402 Minor
create-emdash Minor
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/core size/XL labels Jun 29, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 567 lines across 12 files. Large PRs are harder to review and more likely to be closed without review.

If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs.

See CONTRIBUTING.md for contribution guidelines.

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@1648

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@1648

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@1648

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@1648

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@1648

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@1648

emdash

npm i https://pkg.pr.new/emdash@1648

create-emdash

npm i https://pkg.pr.new/create-emdash@1648

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@1648

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@1648

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@1648

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@1648

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@1648

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@1648

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@1648

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@1648

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@1648

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@1648

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@1648

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@1648

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@1648

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@1648

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@1648

commit: b49a2f2

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach

This is the right change for the right problem. Exact-slug taxonomy filters cannot express "this term or anything under it", and the only workaround (enumerating every descendant slug) blows past D1's bind-parameter limit on deep trees. Adding a first-class { subtree } operator and resolving descendants in SQL with a recursive CTE fits EmDash's architecture cleanly: it reuses the existing translation_group-aware model from migration 045, keeps the parameter count constant, and doesn't disturb the default exact-slug behavior.

What I checked

  • SQL safety: The recursive CTEs use Kysely's sql tagged template for values and sql.ref() for identifiers; the dynamic table/collection names are validated via getTableName/getTaxonomyNames. No raw interpolation of user slugs.
  • Locale / i18n correctness: The subtree walk uses translation_group/parent_id, so matches are locale-agnostic in the same way content_taxonomies.taxonomy_id is. The loader's outer locale filter still scopes the returned entries.
  • Authorization: The admin terms route still checks taxonomies:read; the new ?rollup query param is just passed through.
  • Cache invalidation: getTaxonomyTerms({ rollup: true }) is still wrapped in the existing cachedQuery/requestCached layers with a cache key that includes r1, so term mutations via invalidateTermCache() bust it correctly.
  • Tests: Dialect-parametric tests cover single root, multiple roots, the >999-descendant overflow guard, mixing exact + subtree filters, empty-root short-circuit, cross-locale group matching, keyset pagination, and distinct-entry rollup counts. A changeset is present.

Headline conclusion

The code is clean and I don't see any blocking bugs. I have one small suggestion: the public where docstrings in loader.ts and query.ts list exact/array/range examples but don't mention the new subtree operator, so developers won't discover it from autocomplete/docs.

Process note: Per AGENTS.md, new features require a maintainer-approved Discussion. This PR links to Discussion #1647, which is currently in Ideas and awaiting approval. The implementation looks ready, but merge should wait for that approval.


Findings

  • [suggestion] packages/core/src/loader.ts:643

    The public where docstring lists exact, byline, field, and range examples but omits the new subtree operator. Add a usage example so callers discover the feature through API docs/autocomplete.

    	 * @example { published_at: { gte: '2024-01-01', lt: '2025-01-01' } } - date range
    	 * @example { category: { subtree: 'news' } } - match a term and all descendants
    
  • [suggestion] packages/core/src/query.ts:124

    The public where docstring lists exact/array/byline/field/range examples but does not mention the new subtree operator added by this PR. Add an example so the public API surface is documented.

    	 * @example { published_at: { gte: '2024-01-01', lt: '2025-01-01' } } - Date range
    	 * @example { category: { subtree: 'news' } } - Match a term and all its descendants
    

Postgres COUNT() returns bigint as a string, so getTaxonomyTermCounts
returned "1" instead of 1 under the pg driver, failing the rollup test's
exact-count assertion. Coerce with Number(), matching countEntriesForSubtrees.

Also document the new `subtree` where-operator in the loader/query docstrings
(review suggestion).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Jun 29, 2026
@MA2153
MA2153 marked this pull request as draft June 29, 2026 13:16

@MA2153 MA2153 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RETRACTED

@github-actions

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@github-actions github-actions Bot removed the review/needs-rereview Author pushed changes since the last review label Jun 29, 2026

@MA2153 MA2153 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RETRACTED

@MA2153
MA2153 force-pushed the feat/taxonomy-subtree-where-filter branch from d3cce3d to 6690313 Compare June 30, 2026 10:02
@MA2153
MA2153 marked this pull request as ready for review July 1, 2026 22:10
@github-actions github-actions Bot added the review/needs-rereview Author pushed changes since the last review label Jul 1, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a re-review of PR #1648. The previous review found only two docstring omissions in loader.ts and query.ts; those have been addressed — both files now include a { category: { subtree: 'news' } } example in their public where docstrings.

I re-checked the full diff head-to-toe and traced the implementation against the architecture notes in AGENTS.md:

  • Approach: Adds a first-class { subtree } operator to taxonomy where filters and resolves descendants with a recursive CTE in SQL. This directly solves the stated problem (exact-slug filters cannot express "this term or anything under it", and enumerating descendants overflows D1's bind-parameter limit). It reuses the translation_group-aware model from migration 045, leaves exact matching untouched, and adds an opt-in rollup count mode.
  • SQL safety: The recursive CTE uses Kysely's sql tagged template for all user values (type, f.name, and each root slug) and sql.ref(tableName) for the content table identifier. No raw string interpolation of identifiers or slugs.
  • Locale correctness: The subtree walk keys off translation_group/parent_id, matching how content_taxonomies.taxonomy_id works. The loader's outer locale filter still scopes the returned entries. The rollup counts are locale-independent by design.
  • Type surface & schemas: WhereSubtree is exported from loader.ts, re-exported from query.ts and the package index. termListQuery correctly coerces ?rollup=1/true/0/false to a boolean and is wired through the admin terms route.
  • Caching: Rollup getTaxonomyTerms uses a distinct cache key (:r1), and subtree counts live under the existing TAXONOMIES object-cache namespace, so invalidateTermCache() / invalidateTaxonomyObjectCache() busts them.
  • Tests: Dialect-parity tests cover single root, multi-root union, the >999-descendant bind-limit guard, mixed exact + subtree filters, empty-root short-circuit, cross-locale group matching, keyset pagination, and distinct-entry rollup counts.

No new blocking issues or regressions were introduced. The implementation is clean and the prior findings are resolved.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 2, 2026
@MA2153
MA2153 marked this pull request as draft July 6, 2026 16:21
@github-actions github-actions Bot removed the review/approved Approved; no new commits since label Jul 6, 2026
@MA2153 MA2153 changed the title Adds subtree (descendant) matching to taxonomy where filters Add subtree (descendant) matching to taxonomy where filters Jul 9, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has been inactive for 14 days. It will be closed automatically in 7 days if there is no further activity.

If you're still working on this, please push an update or leave a comment.

@github-actions github-actions Bot added stale and removed stale labels Jul 24, 2026
@MA2153

MA2153 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Closing in favour of #2293.

This was opened on 2026-06-29. #1962 landed on 2026-07-12 and changed the premise: migration 051 made a taxonomy-filtered listing a single equality seek on

idx_content_taxonomies_pub (taxonomy_id, collection, deleted_at, published_at DESC, entry_id DESC)

That index requires an equality on taxonomy_id. Resolving descendants with a recursive CTE and feeding the filter from it turns that into a set match, so the planner drops the index and falls onto sqlite_autoindex_content_taxonomies_1 (collection=?), walking the collection's entire pivot partition and temp-sorting the matches.

Measured on a schema-faithful repro (1,491-term taxonomy, 5 levels, 200k entries, no ANALYZE): a top-level subtree is 213 terms and reads 28,656 rows to render 10. It also cannot be expressed as a single query at all, since one bound parameter per term exceeds D1's cap of 100.

#2293 proposes resolving the hierarchy at write time instead, which leaves the read path completely unchanged and makes a branch filter the same ~11-row seek as a leaf filter, at any depth. I'll open fresh PRs against that if the direction is approved.

@MA2153 MA2153 closed this Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant