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
5 changes: 5 additions & 0 deletions .changeset/great-cases-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"emdash": patch
---

Fixes taxonomy term counts reading a near-quadratic number of rows on sites with many entries and terms, causing multi-second delays on pages that render term counts or taxonomy filters. Counts are unchanged.
9 changes: 7 additions & 2 deletions packages/core/src/taxonomies/term-counts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ interface CountRow {
* join on `taxonomies.id` — the anchor row (id == group) can be deleted while
* sibling translations survive, and a plain join on `translation_group` would
* multiply counts by the number of locales.
*
* CROSS JOIN with the join predicate in WHERE keeps stats-blind SQLite/D1 from
* reordering content_taxonomies out of the outer position; it touches ec_* only
* by primary key. Postgres treats this as an ordinary inner join and plans freely.
*/
function collectionBranch(
db: Kysely<Database>,
Expand All @@ -46,8 +50,9 @@ function collectionBranch(
return sql`
SELECT ct.taxonomy_id AS taxonomy_id, COUNT(*) AS count
FROM content_taxonomies AS ct
INNER JOIN ${sql.ref(`ec_${collection}`)} AS e ON e.id = ct.entry_id
WHERE ct.collection = ${collection}
CROSS JOIN ${sql.ref(`ec_${collection}`)} AS e
WHERE e.id = ct.entry_id
AND ct.collection = ${collection}
AND ct.taxonomy_id IN (SELECT translation_group FROM taxonomies WHERE name = ${taxonomyName})
AND ${buildStatusCondition(db, "published", "e")}
AND e.deleted_at IS NULL
Expand Down
111 changes: 111 additions & 0 deletions packages/core/tests/integration/taxonomy-term-counts-plan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* SQLite query-plan regression guard for the consolidated term-count query.
* Output correctness is covered by unit/taxonomies/term-counts; this asserts
* the planner drives from content_taxonomies, not from ec_*.
*/
Comment thread
MA2153 marked this conversation as resolved.

import Database from "better-sqlite3";
import { Kysely, SqliteDialect } from "kysely";
import { afterEach, beforeEach, expect, it } from "vitest";

import { runMigrations } from "../../src/database/migrations/runner.js";
import { ContentRepository } from "../../src/database/repositories/content.js";
import { TaxonomyRepository } from "../../src/database/repositories/taxonomy.js";
import type { Database as DatabaseSchema } from "../../src/database/types.js";
import { SchemaRegistry } from "../../src/schema/registry.js";
import { fetchVisibleTermCounts } from "../../src/taxonomies/term-counts.js";

interface CapturedQuery {
sql: string;
parameters: readonly unknown[];
}

let sqlite: Database.Database;
let db: Kysely<DatabaseSchema>;
let captured: CapturedQuery[];

beforeEach(async () => {
captured = [];
sqlite = new Database(":memory:");
db = new Kysely<DatabaseSchema>({
dialect: new SqliteDialect({ database: sqlite }),
log(event) {
if (event.level === "query") {
captured.push({ sql: event.query.sql, parameters: event.query.parameters });
}
},
});

// No ANALYZE: D1 never maintains sqlite_stat1.
await runMigrations(db);
const registry = new SchemaRegistry(db);
await registry.createCollection({ slug: "post", label: "Posts", labelSingular: "Post" });
await registry.createField("post", { slug: "title", label: "Title", type: "string" });

// eslint-disable-next-line @typescript-eslint/no-explicit-any -- schema vs Database type
const anyDb = db as any;
const content = new ContentRepository(anyDb);
const tax = new TaxonomyRepository(anyDb);

// Enough rows to make the two access paths visually distinct.
const terms = [];
for (let i = 0; i < 5; i++) {
terms.push(
await tax.create({ name: "category", slug: `term-${i}`, label: `Term ${i}`, locale: "en" }),
);
}
for (let i = 0; i < 20; i++) {
const post = await content.create({
type: "post",
slug: `post-${i}`,
data: { title: `Post ${i}` },
status: "published",
locale: "en",
});
await tax.attachToEntry("post", post.id, terms[i % terms.length]!.id);
}
});

afterEach(async () => {
await db.destroy();
});

/** better-sqlite3 only binds primitives; coerce the JS values Kysely captured. */
function bindable(p: unknown): unknown {
if (typeof p === "boolean") return p ? 1 : 0;
if (p instanceof Date) return p.toISOString();
if (p === undefined) return null;
return p;
}

function explain(query: CapturedQuery): string {
const rows = sqlite
.prepare(`EXPLAIN QUERY PLAN ${query.sql}`)
.all(...query.parameters.map(bindable)) as { detail: string }[];
return rows.map((r) => r.detail).join("\n");
}

async function countQueryPlan(): Promise<string> {
captured = [];
await fetchVisibleTermCounts(db, "category", ["post"]);
const query = captured.find((q) => q.sql.includes("content_taxonomies"));
expect(query, "expected a term-count query against the pivot").toBeDefined();
return explain(query!);
}

it("seeks the terms on a content_taxonomies index rather than probing the pivot per entry", async () => {
const plan = await countQueryPlan();

// The pivot must be entered on a taxonomy_id-leading index.
expect(plan).toMatch(/SEARCH ct USING (COVERING )?INDEX idx_content_taxonomies/);
expect(plan).not.toContain("sqlite_autoindex_content_taxonomies_1");
expect(plan).not.toContain("SCAN ct");
});

it("touches the content table only by primary key", async () => {
const plan = await countQueryPlan();

expect(plan).toContain("SEARCH e USING");
expect(plan).toMatch(/SEARCH e USING (COVERING )?INDEX sqlite_autoindex_ec_post_1 \(id=\?\)/);
expect(plan).not.toContain("SCAN e");
});
Loading
Loading