Skip to content

Commit 8b8a428

Browse files
committed
perf(taxonomies): join locale fallbacks once
1 parent 455c561 commit 8b8a428

6 files changed

Lines changed: 132 additions & 114 deletions

File tree

packages/core/src/api/handlers/taxonomies.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,15 @@ export async function handleTermList(
578578
options: { locale?: string; includeCounts?: boolean; resolveFallback?: boolean } = {},
579579
): Promise<ApiResult<TermListResponse>> {
580580
try {
581+
if (options.resolveFallback && !options.locale) {
582+
return {
583+
success: false,
584+
error: {
585+
code: "VALIDATION_ERROR",
586+
message: "A locale is required when resolving taxonomy fallbacks",
587+
},
588+
};
589+
}
581590
// Definitions are per-locale but terms aren't bound to the def's locale —
582591
// just ensure the taxonomy exists somewhere.
583592
const lookup = await requireTaxonomyDef(db, taxonomyName);

packages/core/src/loader.ts

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -124,25 +124,22 @@ function foldedHydrationSelects(db: Kysely<any>, type: string, outer: string) {
124124
const agg = (inner: RawBuilder<unknown>) =>
125125
pg ? sql`coalesce(json_agg(${inner}), '[]'::json)` : sql`json_group_array(${inner})`;
126126

127-
// Pin the join order for the per-entry hydration subqueries on SQLite (#1722).
128-
// SQLite honours `CROSS JOIN` ordering, forcing the join to drive from the
129-
// pivot (`content_taxonomies` / `_emdash_content_bylines`) by its content
130-
// reference and probe the term/byline table by
131-
// `translation_group`. Without it, a stats-blind D1 planner (D1 never runs
132-
// ANALYZE / maintains `sqlite_stat1`) is free to drive the correlated
133-
// subquery from `taxonomies`/`_emdash_bylines` by `locale`, scanning every
134-
// row in the locale per emitted entry. Postgres keeps statistics and rejects
135-
// `CROSS JOIN … ON`, so it stays a plain `JOIN` there.
127+
// Pin the byline join order on SQLite (#1722). Taxonomy hydration uses
128+
// LEFT JOINs below, whose order is already fixed by SQL semantics. SQLite
129+
// honours `CROSS JOIN` ordering, forcing the byline subquery to drive from
130+
// its pivot by content reference and probe by translation_group. Postgres
131+
// keeps statistics and rejects `CROSS JOIN … ON`, so it stays a plain JOIN.
136132
const foldJoin = pg ? sql`JOIN` : sql`CROSS JOIN`;
137133

138134
const termObj = obj(
139-
"'id', t.id, 'name', t.name, 'slug', t.slug, 'label', t.label, 'parent_id', t.parent_id, 'locale', t.locale, 'translation_group', t.translation_group",
135+
"'id', coalesce(exact_term.id, default_term.id), 'name', coalesce(exact_term.name, default_term.name), 'slug', coalesce(exact_term.slug, default_term.slug), 'label', coalesce(exact_term.label, default_term.label), 'parent_id', coalesce(exact_term.parent_id, default_term.parent_id), 'locale', coalesce(exact_term.locale, default_term.locale), 'translation_group', coalesce(exact_term.translation_group, default_term.translation_group)",
140136
);
141-
const defaultLocale =
142-
virtualConfig?.i18n?.defaultLocale ?? getI18nConfig()?.defaultLocale ?? "en";
143-
// The EXISTS arm prevents the default sibling from joining when the group
144-
// has an exact entry-locale row, so every assignment contributes at most one term.
145-
const terms = sql`(SELECT ${agg(termObj)} FROM ${sql.ref("content_taxonomies")} AS ct ${foldJoin} ${sql.ref("taxonomies")} AS t ON t.translation_group = ct.taxonomy_id AND t.locale = CASE WHEN EXISTS (SELECT 1 FROM ${sql.ref("taxonomies")} AS exact_term WHERE exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = ${o}.locale) THEN ${o}.locale ELSE ${defaultLocale} END WHERE ct.collection = ${type} AND ct.entry_id = ${o}.translation_group) AS ${sql.ref("_emdash_terms")}`;
137+
const defaultLocale = getI18nConfig()?.defaultLocale ?? "en";
138+
const selectedTermId = sql`coalesce(exact_term.id, default_term.id)`;
139+
const termAgg = pg
140+
? sql`coalesce(json_agg(${termObj}) FILTER (WHERE ${selectedTermId} IS NOT NULL), '[]'::json)`
141+
: sql`json_group_array(${termObj}) FILTER (WHERE ${selectedTermId} IS NOT NULL)`;
142+
const terms = sql`(SELECT ${termAgg} FROM ${sql.ref("content_taxonomies")} AS ct LEFT JOIN ${sql.ref("taxonomies")} AS exact_term ON exact_term.translation_group = ct.taxonomy_id AND exact_term.locale = ${o}.locale LEFT JOIN ${sql.ref("taxonomies")} AS default_term ON default_term.translation_group = ct.taxonomy_id AND default_term.locale = ${defaultLocale} WHERE ct.collection = ${type} AND ct.entry_id = ${o}.translation_group) AS ${sql.ref("_emdash_terms")}`;
146143

147144
const bylineInner = obj(
148145
"'id', b.id, 'slug', b.slug, 'displayName', b.display_name, 'bio', b.bio, 'avatarMediaId', b.avatar_media_id, 'avatarStorageKey', m.storage_key, 'avatarAlt', m.alt, 'avatarBlurhash', m.blurhash, 'avatarDominantColor', m.dominant_color, 'websiteUrl', b.website_url, 'userId', b.user_id, 'isGuest', b.is_guest, 'createdAt', b.created_at, 'updatedAt', b.updated_at, 'locale', b.locale, 'translationGroup', b.translation_group",

packages/core/src/taxonomies/index.ts

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,30 +68,27 @@ async function selectEntryTermRows(
6868
const defaultLocale = getI18nConfig()?.defaultLocale ?? "en";
6969
const result = await sql<EntryTermRow>`
7070
SELECT content.id AS entry_id,
71-
terms.id,
72-
terms.name,
73-
terms.slug,
74-
terms.label,
75-
terms.parent_id,
76-
terms.locale,
77-
terms.translation_group
71+
coalesce(exact_term.id, default_term.id) AS id,
72+
coalesce(exact_term.name, default_term.name) AS name,
73+
coalesce(exact_term.slug, default_term.slug) AS slug,
74+
coalesce(exact_term.label, default_term.label) AS label,
75+
coalesce(exact_term.parent_id, default_term.parent_id) AS parent_id,
76+
coalesce(exact_term.locale, default_term.locale) AS locale,
77+
coalesce(exact_term.translation_group, default_term.translation_group) AS translation_group
7878
FROM ${sql.ref(tableName)} AS content
7979
INNER JOIN content_taxonomies AS pivot
8080
ON pivot.entry_id = content.translation_group
8181
AND pivot.collection = ${collection}
82-
INNER JOIN taxonomies AS terms
83-
ON terms.translation_group = pivot.taxonomy_id
84-
AND terms.locale = CASE
85-
WHEN EXISTS (
86-
SELECT 1 FROM taxonomies AS exact_term
87-
WHERE exact_term.translation_group = pivot.taxonomy_id
88-
AND exact_term.locale = ${preferredLocale}
89-
) THEN ${preferredLocale}
90-
ELSE ${defaultLocale}
91-
END
82+
LEFT JOIN taxonomies AS exact_term
83+
ON exact_term.translation_group = pivot.taxonomy_id
84+
AND exact_term.locale = ${preferredLocale}
85+
LEFT JOIN taxonomies AS default_term
86+
ON default_term.translation_group = pivot.taxonomy_id
87+
AND default_term.locale = ${defaultLocale}
9288
WHERE content.id IN (${sql.join(entryIds.map((id) => sql`${id}`))})
93-
${taxonomyName ? sql`AND terms.name = ${taxonomyName}` : sql``}
94-
ORDER BY terms.label ASC
89+
AND coalesce(exact_term.id, default_term.id) IS NOT NULL
90+
${taxonomyName ? sql`AND coalesce(exact_term.name, default_term.name) = ${taxonomyName}` : sql``}
91+
ORDER BY coalesce(exact_term.label, default_term.label) ASC
9592
`.execute(db);
9693
return result.rows;
9794
}

packages/core/tests/integration/taxonomies/taxonomy-locale-terms.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,21 @@ describeEachDialect("content terms route locale-awareness (#1218)", (dialect) =>
493493
expect.objectContaining({ id: frSports.id, locale: "fr" }),
494494
]);
495495
});
496+
497+
it("rejects fallback-resolved term lists without a requested locale", async () => {
498+
const listed = await handleTermList(ctx.db, "tag", {
499+
resolveFallback: true,
500+
includeCounts: false,
501+
});
502+
503+
expect(listed).toEqual({
504+
success: false,
505+
error: {
506+
code: "VALIDATION_ERROR",
507+
message: "A locale is required when resolving taxonomy fallbacks",
508+
},
509+
});
510+
});
496511
});
497512

498513
/**

0 commit comments

Comments
 (0)