Add subtree (descendant) matching to taxonomy where filters - #1648
Add subtree (descendant) matching to taxonomy where filters#1648MA2153 wants to merge 19 commits into
where filters#1648Conversation
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 detectedLatest commit: b49a2f2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
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 |
Scope checkThis 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. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
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
sqltagged template for values andsql.ref()for identifiers; the dynamic table/collection names are validated viagetTableName/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 waycontent_taxonomies.taxonomy_idis. The loader's outerlocalefilter still scopes the returned entries. - Authorization: The admin terms route still checks
taxonomies:read; the new?rollupquery param is just passed through. - Cache invalidation:
getTaxonomyTerms({ rollup: true })is still wrapped in the existingcachedQuery/requestCachedlayers with a cache key that includesr1, so term mutations viainvalidateTermCache()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:643The public
wheredocstring lists exact, byline, field, and range examples but omits the newsubtreeoperator. 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:124The public
wheredocstring lists exact/array/byline/field/range examples but does not mention the newsubtreeoperator 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>
Overlapping PRsThis PR modifies files that are also changed by other open PRs:
This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
d3cce3d to
6690313
Compare
There was a problem hiding this comment.
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 taxonomywherefilters 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-inrollupcount mode. - SQL safety: The recursive CTE uses Kysely's
sqltagged template for all user values (type,f.name, and each root slug) andsql.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 howcontent_taxonomies.taxonomy_idworks. The loader's outerlocalefilter still scopes the returned entries. The rollup counts are locale-independent by design. - Type surface & schemas:
WhereSubtreeis exported fromloader.ts, re-exported fromquery.tsand the package index.termListQuerycorrectly coerces?rollup=1/true/0/falseto a boolean and is wired through the admin terms route. - Caching: Rollup
getTaxonomyTermsuses a distinct cache key (:r1), and subtree counts live under the existingTAXONOMIESobject-cache namespace, soinvalidateTermCache()/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.
where filterswhere filters
|
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. |
|
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 That index requires an equality on Measured on a schema-faithful repro (1,491-term taxonomy, 5 levels, 200k entries, no #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. |
What does this PR do?
Adds a
subtreeoperator to collectionwheretaxonomy filters so that selecting a parent term matches that term and all of its descendants, resolved in SQL:Design and rationale: Discussion #1647.
Motivation
The taxonomy
wherefilter 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 newWhereSubtreeoperator added to theWhereValueunion. The loader resolves the subtree server-side from the root slug(s) via aWITH RECURSIVEwalk oftaxonomies.parent_id, matchingcontent_taxonomies.taxonomy_idthrough anEXISTSclause. 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.subtree:is present. It is ignored (with aconsole.warn) on non-taxonomy keys and onbyline; empty roots short-circuit to an empty result rather than emitting invalid SQL. The default exact-slug path is unchanged.rollupcounts.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.describeEachDialect(SQLite locally, Postgres in CI).Type of change
Checklist
pnpm typecheckpassespnpm lintpasses (0 diagnostics)pnpm testpassespnpm formathas been runmessages.pochanges included (n/a for i18n)emdash: minor)AI-generated code disclosure
Screenshots / test output
Dialect-parity tests run under
describeEachDialect(SQLite locally; Postgres in CI viaPG_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 }), andhandleTermList({ rollup }).schemas.test.ts—?rollupquery-param coercion (1/true/0/false).🤖 Generated with Claude Code