Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fix-taxonomy-filter-postgres.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 9 additions & 8 deletions packages/core/src/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -720,18 +720,19 @@ export function emdashLoader(): LiveLoader<EntryData, EntryFilter, CollectionFil
fieldConds.length > 0 ? sql`${sql.join(fieldConds, sql` AND `)}` : null;

result = await sql<Record<string, unknown>>`
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``}
Expand Down
130 changes: 130 additions & 0 deletions packages/core/tests/unit/loader-taxonomy-filter.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading