Skip to content

Commit 932f4ba

Browse files
MA2153claude
andauthored
fix(core): add composite taxonomies(name, locale) index (emdash-cms#1729)
TaxonomyRepository.findByName filters WHERE name = ? AND locale = ?, but taxonomies had only separate single-column indexes on name and locale. On SQLite/D1 with no sqlite_stat1 the planner picked idx_taxonomies_locale and read every term in the locale, filtering name in memory once per facet rendered. Migration 049 adds a composite idx_taxonomies_name_locale so the planner searches only the one taxonomy's terms, and drops the now redundant single-column idx_taxonomies_name (the composite's leftmost prefix covers every name-only lookup). idx_taxonomies_locale stays for locale-only lookups. Forward-only, idempotent, create-before-drop. Closes emdash-cms#1723 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f03f44f commit 932f4ba

4 files changed

Lines changed: 109 additions & 0 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+
Speeds up taxonomy term lookups on SQLite/D1 by adding a composite `taxonomies(name, locale)` index. Previously the query planner scanned every term in a locale to resolve a single taxonomy, so pages rendering several facets paid a full-locale scan per facet on sites with large taxonomies. A forward-only migration adds the index and drops the now-redundant single-column name index.
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { Kysely } from "kysely";
2+
3+
/**
4+
* Add composite `idx_taxonomies_name_locale` on `taxonomies(name, locale)` (#1723).
5+
*
6+
* `TaxonomyRepository.findByName` filters `WHERE name = ? AND locale = ?`, but
7+
* the only indexes were the single-column `idx_taxonomies_name(name)` and
8+
* `idx_taxonomies_locale(locale)`. On SQLite/D1 with no `sqlite_stat1` the
9+
* planner picks `idx_taxonomies_locale` and reads *every* term in the locale,
10+
* filtering `name` in memory — once per facet rendered. On a site where one
11+
* taxonomy dominates a locale, each small facet still walks the whole locale.
12+
*
13+
* The composite resolves both equalities, so the planner searches only the one
14+
* taxonomy's terms. Its leftmost prefix (`name`) also serves every name-only
15+
* lookup, so it supersedes `idx_taxonomies_name`, which we drop. The
16+
* single-column `idx_taxonomies_locale` stays for locale-only lookups.
17+
*
18+
* Strictly additive: no query changes, and the dropped index is fully covered
19+
* by the new composite's prefix. Create-before-drop keeps name lookups indexed
20+
* at every point. Both statements are idempotent so a partial apply can retry.
21+
*/
22+
export async function up(db: Kysely<unknown>): Promise<void> {
23+
await db.schema
24+
.createIndex("idx_taxonomies_name_locale")
25+
.ifNotExists()
26+
.on("taxonomies")
27+
.columns(["name", "locale"])
28+
.execute();
29+
30+
await db.schema.dropIndex("idx_taxonomies_name").ifExists().execute();
31+
}
32+
33+
export async function down(db: Kysely<unknown>): Promise<void> {
34+
await db.schema
35+
.createIndex("idx_taxonomies_name")
36+
.ifNotExists()
37+
.on("taxonomies")
38+
.column("name")
39+
.execute();
40+
41+
await db.schema.dropIndex("idx_taxonomies_name_locale").ifExists().execute();
42+
}

packages/core/src/database/migrations/runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import * as m045 from "./045_taxonomy_parent_group.js";
5050
import * as m046 from "./046_media_usage_index.js";
5151
import * as m047 from "./047_restore_taxonomy_parent_index.js";
5252
import * as m048 from "./048_restore_content_taxonomies_term_index.js";
53+
import * as m049 from "./049_taxonomies_name_locale_index.js";
5354

5455
const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
5556
"001_initial": m001,
@@ -99,6 +100,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
99100
"046_media_usage_index": m046,
100101
"047_restore_taxonomy_parent_index": m047,
101102
"048_restore_content_taxonomies_term_index": m048,
103+
"049_taxonomies_name_locale_index": m049,
102104
});
103105

104106
/** Total number of registered migrations. Exported for use in tests. */

packages/core/tests/integration/database/migrations.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ describe("Database Migrations (Integration)", () => {
134134
"046_media_usage_index",
135135
"047_restore_taxonomy_parent_index",
136136
"048_restore_content_taxonomies_term_index",
137+
"049_taxonomies_name_locale_index",
137138
];
138139

139140
await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute();
@@ -367,6 +368,65 @@ describe("Database Migrations (Integration)", () => {
367368
expect(names).toContain("idx_content_taxonomies_term");
368369
});
369370

371+
it("should replace idx_taxonomies_name with composite idx_taxonomies_name_locale (#1723)", async () => {
372+
await runMigrations(db);
373+
374+
const indexes = await sql<{ name: string }>`
375+
SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'taxonomies'
376+
`.execute(db);
377+
const names = new Set(indexes.rows.map((r) => r.name));
378+
379+
// The composite (name, locale) index is added...
380+
expect(names).toContain("idx_taxonomies_name_locale");
381+
// ...and it supersedes the single-column name index (leftmost prefix
382+
// covers every name-only lookup), so the redundant one is dropped.
383+
expect(names).not.toContain("idx_taxonomies_name");
384+
});
385+
386+
it("plans findByName(name, locale) through the composite index instead of scanning the locale (regression for #1723)", async () => {
387+
await runMigrations(db);
388+
389+
// One taxonomy dominates the locale; a small facet shares it. Without the
390+
// composite index the planner picks idx_taxonomies_locale and reads every
391+
// term in the locale to filter `name` in memory — per facet fetched.
392+
for (let i = 0; i < 40; i++) {
393+
await db
394+
.insertInto("taxonomies")
395+
.values({
396+
id: `tag-${i}`,
397+
name: "tag",
398+
slug: `tag-${i}`,
399+
label: `Tag ${i}`,
400+
locale: "en",
401+
})
402+
.execute();
403+
}
404+
for (let i = 0; i < 3; i++) {
405+
await db
406+
.insertInto("taxonomies")
407+
.values({
408+
id: `cat-${i}`,
409+
name: "category",
410+
slug: `cat-${i}`,
411+
label: `Category ${i}`,
412+
locale: "en",
413+
})
414+
.execute();
415+
}
416+
417+
// Exact query shape emitted by TaxonomyRepository.findByName(name, { locale }).
418+
const plan = await sql<{ detail: string }>`
419+
EXPLAIN QUERY PLAN
420+
SELECT * FROM "taxonomies"
421+
WHERE "name" = ${"category"} AND "locale" = ${"en"}
422+
ORDER BY "label" ASC, "id" ASC
423+
`.execute(db);
424+
const details = plan.rows.map((r) => r.detail).join("\n");
425+
426+
expect(details).toContain("idx_taxonomies_name_locale");
427+
expect(details).not.toContain("idx_taxonomies_locale");
428+
});
429+
370430
it("should create content_taxonomies junction table", async () => {
371431
await runMigrations(db);
372432

0 commit comments

Comments
 (0)