Skip to content

Commit abb96ce

Browse files
fix(core): backfill locale casing on ec_* tables (emdash-cms#1572)
Addresses review feedback on emdash-cms#1572: removing localeCode's lowercasing transform fixed new locale-filtered queries against the canonical casing, but pre-existing rows already stored under the lowercased form (e.g. zh-tw) were still missed by an exact locale = 'zh-TW' filter. Adds migration 044, which canonicalizes the locale column on every ec_* table against getI18nConfig().locales, case-insensitively. No-op when i18n isn't configured. Not reversible (down is a no-op) -- the original incorrect casing isn't recoverable and re-lowercasing would reintroduce the bug. Updates the "partially applied" migration-runner test to include the new trailing migration.
1 parent e288db7 commit abb96ce

5 files changed

Lines changed: 142 additions & 1 deletion

File tree

.changeset/localecode-preserve-casing.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"emdash": patch
33
---
44

5-
Fixes the content and search APIs returning zero results when filtering by a locale with an uppercase region or script subtag (e.g. `?locale=zh-TW`, `pt-BR`, `zh-Hant`). The `localeCode` validator lowercased the value, but config locales, the stored `locale` column, and the public query path all preserve the original casing, so the filter matched nothing. Validation stays case-insensitive; the value is now preserved verbatim. The taxonomy and menu endpoints' shared `?locale=` filter now applies the same BCP-47 validation, so a malformed locale is rejected with a clear error instead of silently matching nothing. Closes #1551.
5+
Fixes the content and search APIs returning zero results when filtering by a locale with an uppercase region or script subtag (e.g. `?locale=zh-TW`, `pt-BR`, `zh-Hant`). The `localeCode` validator lowercased the value, but config locales, the stored `locale` column, and the public query path all preserve the original casing, so the filter matched nothing. Validation stays case-insensitive; the value is now preserved verbatim. The taxonomy and menu endpoints' shared `?locale=` filter now applies the same BCP-47 validation, so a malformed locale is rejected with a clear error instead of silently matching nothing. A migration backfills existing content rows that were previously stored under the lowercased casing so upgraded sites match immediately, not just new content. Closes #1551.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { Kysely } from "kysely";
2+
import { sql } from "kysely";
3+
4+
import { getI18nConfig } from "../../i18n/config.js";
5+
import { listTablesLike } from "../dialect-helpers.js";
6+
7+
/**
8+
* Backfill migration for #1572.
9+
*
10+
* `contentCreateBody`'s `locale` field used to lowercase every explicit
11+
* value (`localeCode`'s `.transform()`), so a site with a configured locale
12+
* like `zh-TW` could end up with rows stored as `zh-tw`. The transform was
13+
* removed so canonical-cased queries (`?locale=zh-TW`) now match, but that
14+
* only fixes future writes -- existing rows already stored under the
15+
* lowercased form are still missed by an exact-match `locale = 'zh-TW'`
16+
* filter.
17+
*
18+
* For every `ec_*` content table, canonicalize any row whose `locale`
19+
* case-insensitively matches a configured locale but isn't stored in that
20+
* locale's canonical casing.
21+
*/
22+
export async function up(db: Kysely<unknown>): Promise<void> {
23+
const locales = getI18nConfig()?.locales ?? [];
24+
if (locales.length === 0) return;
25+
26+
const tableNames = await listTablesLike(db, "ec_%");
27+
28+
for (const tableName of tableNames) {
29+
const table = sql.ref(tableName);
30+
for (const locale of locales) {
31+
await sql`
32+
UPDATE ${table}
33+
SET locale = ${locale}
34+
WHERE lower(locale) = lower(${locale}) AND locale != ${locale}
35+
`.execute(db);
36+
}
37+
}
38+
}
39+
40+
export async function down(_db: Kysely<unknown>): Promise<void> {
41+
// Not reversible: the original (incorrectly lowercased) casing is not
42+
// recoverable, and re-lowercasing would reintroduce the bug this fixes.
43+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import * as m040 from "./040_byline_i18n.js";
4545
import * as m041 from "./041_content_locale_list_index.js";
4646
import * as m042 from "./042_byline_fields.js";
4747
import * as m043 from "./043_content_references.js";
48+
import * as m044 from "./044_canonicalize_locale_casing.js";
4849

4950
const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
5051
"001_initial": m001,
@@ -89,6 +90,7 @@ const MIGRATIONS: Readonly<Record<string, Migration>> = Object.freeze({
8990
"041_content_locale_list_index": m041,
9091
"042_byline_fields": m042,
9192
"043_content_references": m043,
93+
"044_canonicalize_locale_casing": m044,
9294
});
9395

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ describe("Database Migrations (Integration)", () => {
126126
"041_content_locale_list_index",
127127
"042_byline_fields",
128128
"043_content_references",
129+
"044_canonicalize_locale_casing",
129130
];
130131

131132
await db.deleteFrom("_emdash_migrations").where("name", "in", trailing).execute();
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import type { Kysely } from "kysely";
2+
import { sql } from "kysely";
3+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
4+
5+
import { up } from "../../../../src/database/migrations/044_canonicalize_locale_casing.js";
6+
import { ContentRepository } from "../../../../src/database/repositories/content.js";
7+
import type { Database } from "../../../../src/database/types.js";
8+
import { getI18nConfig, setI18nConfig } from "../../../../src/i18n/config.js";
9+
import { createPostFixture } from "../../../utils/fixtures.js";
10+
import { setupTestDatabaseWithCollections, teardownTestDatabase } from "../../../utils/test-db.js";
11+
12+
/**
13+
* Regression coverage for #1572.
14+
*
15+
* `contentCreateBody` used to lowercase every explicit `locale` value, so a
16+
* site with a configured locale like `zh-TW` could have existing rows stored
17+
* as `zh-tw`. Removing that transform fixed new queries against the
18+
* canonical casing, but pre-existing rows would still be missed by an exact
19+
* `locale = 'zh-TW'` filter without a data backfill.
20+
*/
21+
describe("migration 044: canonicalize locale casing (#1572)", () => {
22+
let db: Kysely<Database>;
23+
const previousI18nConfig = getI18nConfig();
24+
25+
beforeEach(async () => {
26+
db = await setupTestDatabaseWithCollections();
27+
});
28+
29+
afterEach(async () => {
30+
setI18nConfig(previousI18nConfig);
31+
await teardownTestDatabase(db);
32+
});
33+
34+
it("rewrites rows stored in the wrong case to the configured locale's canonical casing", async () => {
35+
setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] });
36+
37+
const repo = new ContentRepository(db);
38+
await repo.create(
39+
createPostFixture({ slug: "guide", status: "published", data: { title: "Guide" } }),
40+
);
41+
// Simulate a pre-fix row: written under the lowercased locale.
42+
await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'guide'`.execute(db);
43+
44+
await up(db);
45+
46+
const row = await db
47+
.selectFrom("ec_post")
48+
.select("locale")
49+
.where("slug", "=", "guide")
50+
.executeTakeFirstOrThrow();
51+
expect(row.locale).toBe("zh-TW");
52+
});
53+
54+
it("leaves rows already in canonical casing untouched", async () => {
55+
setI18nConfig({ defaultLocale: "en", locales: ["en", "zh-TW"] });
56+
57+
const repo = new ContentRepository(db);
58+
await repo.create(
59+
createPostFixture({
60+
slug: "already-correct",
61+
status: "published",
62+
data: { title: "Correct" },
63+
locale: "zh-TW",
64+
}),
65+
);
66+
67+
await up(db);
68+
69+
const row = await db
70+
.selectFrom("ec_post")
71+
.select("locale")
72+
.where("slug", "=", "already-correct")
73+
.executeTakeFirstOrThrow();
74+
expect(row.locale).toBe("zh-TW");
75+
});
76+
77+
it("does nothing when i18n is not configured", async () => {
78+
setI18nConfig(null);
79+
80+
const repo = new ContentRepository(db);
81+
await repo.create(
82+
createPostFixture({ slug: "no-i18n", status: "published", data: { title: "No i18n" } }),
83+
);
84+
await sql`UPDATE ec_post SET locale = 'zh-tw' WHERE slug = 'no-i18n'`.execute(db);
85+
86+
await up(db);
87+
88+
const row = await db
89+
.selectFrom("ec_post")
90+
.select("locale")
91+
.where("slug", "=", "no-i18n")
92+
.executeTakeFirstOrThrow();
93+
expect(row.locale).toBe("zh-tw");
94+
});
95+
});

0 commit comments

Comments
 (0)