From fb3d94e33ebc496e126cb17f15d1b089cda5affc Mon Sep 17 00:00:00 2001 From: "emdashbot[bot]" Date: Fri, 5 Jun 2026 10:42:34 +0000 Subject: [PATCH] fix(core/loader): avoid SELECT DISTINCT * on taxonomy-filtered queries for Postgres compatibility (#1355) --- .changeset/fix-taxonomy-filter-postgres.md | 7 + packages/core/src/loader.ts | 17 +-- .../tests/unit/loader-taxonomy-filter.test.ts | 130 ++++++++++++++++++ 3 files changed, 146 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-taxonomy-filter-postgres.md create mode 100644 packages/core/tests/unit/loader-taxonomy-filter.test.ts diff --git a/.changeset/fix-taxonomy-filter-postgres.md b/.changeset/fix-taxonomy-filter-postgres.md new file mode 100644 index 000000000..0a4efac15 --- /dev/null +++ b/.changeset/fix-taxonomy-filter-postgres.md @@ -0,0 +1,7 @@ +--- +"emdash": patch +--- + +fix(core/loader): avoid SELECT DISTINCT * on taxonomy-filtered queries for Postgres compatibility (#1355) + +On Postgres, `SELECT DISTINCT *` fails when the content table contains a native `json` column (e.g. from a `portableText` field) because `json` has no equality operator. The taxonomy-filtered query now uses a subquery with `DISTINCT` on the join key (`ct.entry_id`) and selects `*` from the outer query, so filtering works on all dialects without a schema migration. diff --git a/packages/core/src/loader.ts b/packages/core/src/loader.ts index 127bcb9db..4dac0dca5 100644 --- a/packages/core/src/loader.ts +++ b/packages/core/src/loader.ts @@ -720,18 +720,19 @@ export function emdashLoader(): LiveLoader 0 ? sql`${sql.join(fieldConds, sql` AND `)}` : null; result = await sql>` - SELECT DISTINCT ${sql.ref(tableName)}.* FROM ${sql.ref(tableName)} - INNER JOIN content_taxonomies ct - ON ct.collection = ${type} - AND ct.entry_id = ${sql.ref(tableName)}.id - INNER JOIN taxonomies t - ON t.id = ct.taxonomy_id + SELECT * FROM ${sql.ref(tableName)} WHERE ${sql.ref(tableName)}.deleted_at IS NULL AND ${statusCondition} ${localeCondition} ${cursorCond} - AND t.name = ${taxonomyFilter.name} - AND t.slug IN (${sql.join(taxonomyFilter.slugs.map((s) => sql`${s}`))}) + AND ${sql.ref(tableName)}.id IN ( + SELECT DISTINCT ct.entry_id + FROM content_taxonomies ct + INNER JOIN taxonomies t ON t.id = ct.taxonomy_id + WHERE ct.collection = ${type} + AND t.name = ${taxonomyFilter.name} + AND t.slug IN (${sql.join(taxonomyFilter.slugs.map((s) => sql`${s}`))}) + ) ${fieldCondsSQL ? sql`AND ${fieldCondsSQL}` : sql``} ${orderByClause} ${fetchLimit ? sql`LIMIT ${fetchLimit}` : sql``} diff --git a/packages/core/tests/unit/loader-taxonomy-filter.test.ts b/packages/core/tests/unit/loader-taxonomy-filter.test.ts new file mode 100644 index 000000000..0a1c35863 --- /dev/null +++ b/packages/core/tests/unit/loader-taxonomy-filter.test.ts @@ -0,0 +1,130 @@ +import { it, expect, beforeEach, afterEach } from "vitest"; + +import { handleContentCreate } from "../../src/api/index.js"; +import { emdashLoader } from "../../src/loader.js"; +import { runWithContext } from "../../src/request-context.js"; +import { + describeEachDialect, + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../utils/test-db.js"; + +/** + * Regression test for #1355: taxonomy filtering on collections with a + * portableText (json) field fails on Postgres because `SELECT DISTINCT *` + * is invalid when the table contains a native `json` column. + * + * The fix restructures the query so DISTINCT only applies to the join key + * (`ct.entry_id`) inside a subquery, with the outer query selecting `*`. + */ +describeEachDialect("Loader taxonomy filter with JSON field (#1355)", (dialect) => { + let ctx: DialectTestContext; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections(dialect); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + }); + + async function createPublishedPost(title: string) { + const result = await handleContentCreate(ctx.db, "post", { + data: { title }, + status: "published", + }); + if (!result.success) throw new Error("Failed to create post"); + return result.data!.item; + } + + it("returns entries when filtering by taxonomy on a collection with a JSON field", async () => { + // Seed taxonomy term and link it to a published post + await ctx.db + .insertInto("taxonomies") + .values({ + id: "tax_cat_news", + name: "category", + slug: "news", + label: "News", + }) + .execute(); + + const post = await createPublishedPost("News Post"); + + await ctx.db + .insertInto("content_taxonomies") + .values({ + collection: "post", + entry_id: post.id, + taxonomy_id: "tax_cat_news", + }) + .execute(); + + // Also create an unrelated post to verify filtering works + await createPublishedPost("Other Post"); + + const loader = emdashLoader(); + const result = await runWithContext({ editMode: false, db: ctx.db }, () => + loader.loadCollection!({ + filter: { type: "post", where: { category: "news" } }, + }), + ); + + expect(result.error).toBeUndefined(); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].data.title).toBe("News Post"); + }); + + it("combines taxonomy filter with a field filter on a JSON-field collection", async () => { + await ctx.db + .insertInto("taxonomies") + .values({ + id: "tax_cat_tech", + name: "category", + slug: "tech", + label: "Tech", + }) + .execute(); + + const postA = await createPublishedPost("Tech Post A"); + const postB = await createPublishedPost("Tech Post B"); + await createPublishedPost("Untagged Post"); + + await ctx.db + .insertInto("content_taxonomies") + .values([ + { collection: "post", entry_id: postA.id, taxonomy_id: "tax_cat_tech" }, + { collection: "post", entry_id: postB.id, taxonomy_id: "tax_cat_tech" }, + ]) + .execute(); + + // Add a field we can filter on + const { SchemaRegistry } = await import("../../src/schema/registry.js"); + const registry = new SchemaRegistry(ctx.db); + await registry.createField("post", { slug: "series", label: "Series", type: "string" }); + + // Update posts to have different series values + await ctx.db + .updateTable("ec_post") + .set({ series: "alpha" }) + .where("id", "=", postA.id) + .execute(); + await ctx.db + .updateTable("ec_post") + .set({ series: "beta" }) + .where("id", "=", postB.id) + .execute(); + + const loader = emdashLoader(); + const result = await runWithContext({ editMode: false, db: ctx.db }, () => + loader.loadCollection!({ + filter: { type: "post", where: { category: "tech", series: "alpha" } }, + }), + ); + + expect(result.error).toBeUndefined(); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].data.title).toBe("Tech Post A"); + }); +});