Skip to content

Commit 226d7f8

Browse files
bdshadowclaude
andcommitted
fix: sort export translations by key name (Sentry TOLGEE-BACKEND-3ED)
The StructureModelBuilder requires translations sorted alphabetically by key name path, but the database collation may return them in a different order. Sort in GenericStructuredFileExporter.prepare() to ensure correct ordering regardless of database collation. Likely triggered by the branching feature (#3246, merged Feb 6) which added a LEFT JOIN on Branch to the export query. The additional join changed the query execution plan, surfacing a collation mismatch between PostgreSQL's locale-aware sorting and Java's byte-by-byte string comparison for keys with punctuation (e.g. "profile.my-goals" vs "profile.my-goals.plan_other"). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fae0255 commit 226d7f8

2 files changed

Lines changed: 27 additions & 1 deletion

File tree

backend/data/src/main/kotlin/io/tolgee/formats/genericStructuredFile/out/GenericStructuredFileExporter.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class GenericStructuredFileExporter(
3939
}
4040

4141
private fun prepare() {
42-
translations.forEach { translation ->
42+
translations.sortedBy { it.key.name }.forEach { translation ->
4343
addTranslationToBuilder(translation)
4444
}
4545
}

backend/data/src/test/kotlin/io/tolgee/unit/formats/json/out/JsonFileExporterTest.kt

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,32 @@ class JsonFileExporterTest {
306306
files["cs-rCZ/hello.json"].assert.isNotNull()
307307
}
308308

309+
@Test
310+
fun `does not throw when translations are unsorted and key is prefix of another`() {
311+
// Regression test: when database collation returns keys in non-alphabetical order,
312+
// the exporter should sort them before building the nested structure.
313+
// Keys like "profile.my-goals" and "profile.my-goals.plan_other" must be sorted
314+
// so the parent path is processed before the child path.
315+
val unsortedKeys = listOf("profile.my-goals.plan_other", "profile.my-goals")
316+
val data = generateTranslationsForUnsortedKeys(unsortedKeys)
317+
val exported = getExporter(data).produceFiles()
318+
val json = exported.getFileTextContent("en.json")
319+
assertThatJson(json) {
320+
node("profile.my-goals").isEqualTo("text")
321+
node("profile.my-goals\\.plan_other").isEqualTo("text")
322+
}
323+
}
324+
325+
private fun generateTranslationsForUnsortedKeys(keys: List<String>): List<ExportTranslationView> {
326+
// Deliberately does NOT sort, to simulate wrong ordering from the database
327+
return keys.map { keyName ->
328+
val key = ExportKeyView(1, keyName, namespace = null)
329+
val trans = ExportTranslationView(1, "text", TranslationState.TRANSLATED, key, "en")
330+
key.translations["en"] = trans
331+
trans
332+
}
333+
}
334+
309335
private fun Map<String, InputStream>.getFileTextContent(fileName: String): String {
310336
return this[fileName]!!.bufferedReader().readText()
311337
}

0 commit comments

Comments
 (0)