Skip to content

Commit afc3a0f

Browse files
MA2153claude
andauthored
fix(loader): apply every taxonomy key in collection where filters (emdash-cms#1481)
A `where` map with more than one taxonomy key dropped all but the first with a warning, returning rows filtered by a single taxonomy instead of the intersection. Collect every taxonomy filter and emit one `EXISTS` clause per taxonomy, AND'd together — each pinning its own `t.name` so slugs never pool across taxonomies (they're unique only within one). This matches how field and byline filters in the same map already compose. Generalizes the empty-slugs short-circuit to any one filter. Closes emdash-cms#1479 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent eb5f7e4 commit afc3a0f

3 files changed

Lines changed: 167 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"emdash": patch
3+
---
4+
5+
Fixes collection `where` filters to apply every taxonomy key instead of silently dropping all but the first. Filtering by two or more taxonomies (e.g. `{ category: ["news"], region: ["emea"] }`) now returns the intersection — entries tagged in each taxonomy — matching how field and byline filters already compose.

packages/core/src/loader.ts

Lines changed: 24 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,12 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
680680

681681
// Separate taxonomy / byline filters from field filters
682682
let result: { rows: Record<string, unknown>[] };
683-
let taxonomyFilter: { name: string; slugs: string[] } | null = null;
683+
// Taxonomy filters AND together: each entry constrains the base
684+
// row to match at least one of its slugs *within that taxonomy*.
685+
// Term slugs are unique only within a taxonomy, so every filter
686+
// keeps its own `name` and emits its own `EXISTS` clause rather
687+
// than pooling slugs into one `IN`.
688+
const taxonomyFilters: { name: string; slugs: string[] }[] = [];
684689
// A byline filter matches entries credited to any of the given
685690
// byline translation groups via the `_emdash_content_bylines`
686691
// junction table. `null` means no byline filter; an empty
@@ -710,14 +715,8 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
710715
);
711716
continue;
712717
}
713-
if (taxonomyFilter) {
714-
console.warn(
715-
`[emdash] where filter: only one taxonomy is supported per query, "${key}" ignored`,
716-
);
717-
continue;
718-
}
719718
const slugs = Array.isArray(value) ? value : [value];
720-
taxonomyFilter = { name: key, slugs };
719+
taxonomyFilters.push({ name: key, slugs });
721720
} else {
722721
fieldFilters[key] = value;
723722
}
@@ -729,7 +728,7 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
729728
// SQL on both dialects).
730729
if (
731730
(bylineFilter && bylineFilter.groups.length === 0) ||
732-
(taxonomyFilter && taxonomyFilter.slugs.length === 0)
731+
taxonomyFilters.some((f) => f.slugs.length === 0)
733732
) {
734733
return { entries: [], cacheHint: { tags: [type] } };
735734
}
@@ -753,16 +752,26 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
753752
const fieldCondsSQL =
754753
fieldConds.length > 0 ? sql`${sql.join(fieldConds, sql` AND `)}` : null;
755754

756-
const taxonomyCond = taxonomyFilter
757-
? sql`AND EXISTS (
755+
// One `EXISTS` per taxonomy, AND'd together: an entry must be
756+
// tagged with a matching term in *every* requested taxonomy.
757+
// Each clause pins its own `t.name`, so slugs never pool
758+
// across taxonomies (they're only unique within one).
759+
const taxonomyCond =
760+
taxonomyFilters.length > 0
761+
? sql`${sql.join(
762+
taxonomyFilters.map(
763+
(f) => sql`AND EXISTS (
758764
SELECT 1 FROM content_taxonomies ct
759765
INNER JOIN taxonomies t ON t.id = ct.taxonomy_id
760766
WHERE ct.collection = ${type}
761767
AND ct.entry_id = ${sql.ref(tableName)}.id
762-
AND t.name = ${taxonomyFilter.name}
763-
AND t.slug IN (${sql.join(taxonomyFilter.slugs.map((s) => sql`${s}`))})
764-
)`
765-
: sql``;
768+
AND t.name = ${f.name}
769+
AND t.slug IN (${sql.join(f.slugs.map((s) => sql`${s}`))})
770+
)`,
771+
),
772+
sql` `,
773+
)}`
774+
: sql``;
766775

767776
// `_emdash_content_bylines.byline_id` stores the byline's
768777
// translation_group (migration 040), so a credit spans every
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import type { Kysely } from "kysely";
2+
import { it, expect, beforeEach, afterEach } from "vitest";
3+
4+
import { handleContentCreate } from "../../src/api/index.js";
5+
import type { Database } from "../../src/database/types.js";
6+
import { emdashLoader } from "../../src/loader.js";
7+
import { runWithContext } from "../../src/request-context.js";
8+
import {
9+
describeEachDialect,
10+
setupForDialectWithCollections,
11+
teardownForDialect,
12+
type DialectName,
13+
type DialectTestContext,
14+
} from "../utils/test-db.js";
15+
16+
describeEachDialect("Loader taxonomy term filter", (dialectName: DialectName) => {
17+
let ctx: DialectTestContext;
18+
let db: Kysely<Database>;
19+
let termSeq = 0;
20+
21+
beforeEach(async () => {
22+
ctx = await setupForDialectWithCollections(dialectName);
23+
db = ctx.db;
24+
termSeq = 0;
25+
});
26+
27+
afterEach(async () => {
28+
await teardownForDialect(ctx);
29+
});
30+
31+
async function createPost(title: string) {
32+
const result = await handleContentCreate(db, "post", {
33+
data: { title },
34+
status: "published",
35+
});
36+
if (!result.success) throw new Error("Failed to create post");
37+
return result.data!.item;
38+
}
39+
40+
/**
41+
* Insert a taxonomy term and return its id. `category` and `tag` are the
42+
* default taxonomy defs seeded by migration 006, so both are recognized as
43+
* taxonomy keys by the `where` filter. We use `id` as the value stored in
44+
* `content_taxonomies.taxonomy_id` (these terms have no translations, so the
45+
* row id coincides with the translation_group the pivot references).
46+
*/
47+
async function term(name: string, slug: string) {
48+
const id = `tax_${name}_${slug}_${termSeq++}`;
49+
await db
50+
.insertInto("taxonomies" as never)
51+
.values({ id, name, slug, label: slug, translation_group: id } as never)
52+
.execute();
53+
return id;
54+
}
55+
56+
async function tag(contentId: string, taxonomyId: string) {
57+
await db
58+
.insertInto("content_taxonomies" as never)
59+
.values({ collection: "post", entry_id: contentId, taxonomy_id: taxonomyId } as never)
60+
.execute();
61+
}
62+
63+
function load(where: Record<string, unknown>) {
64+
const loader = emdashLoader();
65+
return runWithContext({ editMode: false, db }, () =>
66+
loader.loadCollection!({ filter: { type: "post", where: where as never } }),
67+
);
68+
}
69+
70+
it("filters by a single taxonomy term", async () => {
71+
const news = await term("category", "news");
72+
const a = await createPost("In News");
73+
await createPost("Untagged");
74+
await tag(a.id, news);
75+
76+
const result = await load({ category: "news" });
77+
78+
expect(result.entries).toHaveLength(1);
79+
expect(result.entries[0]!.data.title).toBe("In News");
80+
});
81+
82+
it("ANDs across two taxonomies — only entries tagged in BOTH match (#1479)", async () => {
83+
const news = await term("category", "news");
84+
const featured = await term("tag", "featured");
85+
86+
const both = await createPost("News + Featured");
87+
const newsOnly = await createPost("News Only");
88+
const featuredOnly = await createPost("Featured Only");
89+
90+
await tag(both.id, news);
91+
await tag(both.id, featured);
92+
await tag(newsOnly.id, news);
93+
await tag(featuredOnly.id, featured);
94+
95+
// Before the fix, the second taxonomy key ("tag") was silently dropped
96+
// and this returned both "News + Featured" and "News Only".
97+
const result = await load({ category: ["news"], tag: ["featured"] });
98+
99+
expect(result.entries).toHaveLength(1);
100+
expect(result.entries[0]!.data.title).toBe("News + Featured");
101+
});
102+
103+
it("ORs slugs within a taxonomy while ANDing across taxonomies", async () => {
104+
const news = await term("category", "news");
105+
const sports = await term("category", "sports");
106+
const featured = await term("tag", "featured");
107+
108+
// Matches: in (news OR sports) AND featured.
109+
const a = await createPost("News + Featured");
110+
const b = await createPost("Sports + Featured");
111+
const c = await createPost("News, not Featured");
112+
113+
await tag(a.id, news);
114+
await tag(a.id, featured);
115+
await tag(b.id, sports);
116+
await tag(b.id, featured);
117+
await tag(c.id, news);
118+
119+
const result = await load({ category: ["news", "sports"], tag: ["featured"] });
120+
121+
const titles = result.entries.map((e) => e.data.title);
122+
expect(titles).toHaveLength(2);
123+
expect(titles).toContain("News + Featured");
124+
expect(titles).toContain("Sports + Featured");
125+
});
126+
127+
it("returns no entries when any one taxonomy filter is an empty array", async () => {
128+
const news = await term("category", "news");
129+
const post = await createPost("In News");
130+
await tag(post.id, news);
131+
132+
// `category` matches, but the empty `tag` array short-circuits the whole
133+
// query to empty rather than emitting `t.slug IN ()`.
134+
const result = await load({ category: ["news"], tag: [] });
135+
136+
expect(result.entries).toHaveLength(0);
137+
});
138+
});

0 commit comments

Comments
 (0)