Skip to content

Commit 5109c75

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 5109c75

7 files changed

Lines changed: 66 additions & 40 deletions

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/android/out/AndroidSdkFileExporterTest.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,12 @@ class AndroidSdkFileExporterTest {
2323
"cs.json",
2424
"""
2525
|{
26+
| "item": "I will be first %1${'$'}d",
2627
| "key3": {
2728
| "one": "%1${'$'}d den %2${'$'}s",
2829
| "few": "%1${'$'}d dny",
2930
| "other": "%1${'$'}d dní"
30-
| },
31-
| "item": "I will be first %1${'$'}d"
31+
| }
3232
|}
3333
""".trimMargin(),
3434
)
@@ -65,12 +65,12 @@ class AndroidSdkFileExporterTest {
6565
"cs.json",
6666
"""
6767
|{
68+
| "item": "I will be first {icuParam} %1${'$'}d",
6869
| "key3": {
6970
| "one": "%1${'$'}d den %2${'$'}d",
7071
| "few": "%1${'$'}d dny",
7172
| "other": "%1${'$'}d dní"
72-
| },
73-
| "item": "I will be first {icuParam} %1${'$'}d"
73+
| }
7474
|}
7575
""".trimMargin(),
7676
)

backend/data/src/test/kotlin/io/tolgee/unit/formats/apple/out/AppleSdkFileExporterTest.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class AppleSdkFileExporterTest {
2222
"cs.json",
2323
"""
2424
|{
25+
| "item": "I will be first %lld",
2526
| "key3": {
2627
| "variations": {
2728
| "plural": {
@@ -30,8 +31,7 @@ class AppleSdkFileExporterTest {
3031
| "other": "%lld dní"
3132
| }
3233
| }
33-
| },
34-
| "item": "I will be first %lld"
34+
| }
3535
|}
3636
""".trimMargin(),
3737
)
@@ -68,6 +68,7 @@ class AppleSdkFileExporterTest {
6868
"cs.json",
6969
"""
7070
|{
71+
| "item": "I will be first {icuParam} %lld",
7172
| "key3": {
7273
| "variations": {
7374
| "plural": {
@@ -76,8 +77,7 @@ class AppleSdkFileExporterTest {
7677
| "other": "%lld dní"
7778
| }
7879
| }
79-
| },
80-
| "item": "I will be first {icuParam} %lld"
80+
| }
8181
|}
8282
""".trimMargin(),
8383
)

backend/data/src/test/kotlin/io/tolgee/unit/formats/i18next/out/I18nextFileExporterTest.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,10 @@ class I18nextFileExporterTest {
2222
"cs.json",
2323
"""
2424
|{
25+
| "item": "I will be first {icuParam, number}",
2526
| "key3_one": "# den {icuParam}",
2627
| "key3_few": "# dny",
27-
| "key3_other": "# dní",
28-
| "item": "I will be first {icuParam, number}"
28+
| "key3_other": "# dní"
2929
|}
3030
""".trimMargin(),
3131
)
@@ -62,10 +62,10 @@ class I18nextFileExporterTest {
6262
"cs.json",
6363
"""
6464
|{
65+
| "item": "I will be first {icuParam} {{hello, number}}",
6566
| "key3_one": "{{count, number}} den {{icuParam, number}}",
6667
| "key3_few": "{{count, number}} dny",
67-
| "key3_other": "{{count, number}} dní",
68-
| "item": "I will be first {icuParam} {{hello, number}}"
68+
| "key3_other": "{{count, number}} dní"
6969
|}
7070
""".trimMargin(),
7171
)

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ class JsonFileExporterTest {
103103
"cs.json",
104104
"""
105105
|{
106-
| "key3": "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}",
107-
| "item": "I will be first {icuParam, number} '{hey}'"
106+
| "item": "I will be first {icuParam, number} '{hey}'",
107+
| "key3": "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}"
108108
|}
109109
""".trimMargin(),
110110
)
@@ -121,12 +121,12 @@ class JsonFileExporterTest {
121121
"cs.json",
122122
"""
123123
|{
124+
| "item": "I will be first {icuParam, number} '{hey}'",
124125
| "key3": {
125126
| "one": "# den {icuParam}",
126127
| "few": "# dny",
127128
| "other": "# dní"
128-
| },
129-
| "item": "I will be first {icuParam, number} '{hey}'"
129+
| }
130130
|}
131131
""".trimMargin(),
132132
)
@@ -159,8 +159,8 @@ class JsonFileExporterTest {
159159
"cs.json",
160160
"""
161161
|{
162-
| "key3": "{count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}}",
163-
| "item": "I will be first '{'icuParam'}' {hello, number}"
162+
| "item": "I will be first '{'icuParam'}' {hello, number}",
163+
| "key3": "{count, plural, one {# den {icuParam, number}} few {# dny} other {# dní}}"
164164
|}
165165
""".trimMargin(),
166166
)
@@ -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
}

backend/data/src/test/kotlin/io/tolgee/unit/formats/yaml/out/RubyYamlFileExporterTest.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ class RubyYamlFileExporterTest {
1919
"""
2020
|---
2121
|cs:
22+
| indexed params: "I will be first %s, %s"
23+
| named params: "I will be first %{param1}, %{param2}"
24+
| this-is-array:
25+
| - object: "I will be first %<icuParam>d"
2226
| this:
2327
| is:
2428
| nested:
@@ -29,10 +33,6 @@ class RubyYamlFileExporterTest {
2933
| '[1]':
3034
| is:
3135
| collision: "Colission"
32-
| this-is-array:
33-
| - object: "I will be first %<icuParam>d"
34-
| indexed params: "I will be first %s, %s"
35-
| named params: "I will be first %{param1}, %{param2}"
3636
|
3737
""".trimMargin(),
3838
)
@@ -47,11 +47,11 @@ class RubyYamlFileExporterTest {
4747
"""
4848
|---
4949
|cs:
50+
| item: "I will be first {icuParam, number}"
5051
| key3:
5152
| one: "# den {icuParam}"
5253
| few: "# dny"
5354
| other: "# dní"
54-
| item: "I will be first {icuParam, number}"
5555
|
5656
""".trimMargin(),
5757
)
@@ -66,11 +66,11 @@ class RubyYamlFileExporterTest {
6666
"""
6767
|---
6868
|cs:
69+
| item: "I will be first {icuParam} %<hello>d"
6970
| key3:
7071
| one: "%{count} den %<icuParam>d"
7172
| few: "%{count} dny"
7273
| other: "%{count} dní"
73-
| item: "I will be first {icuParam} %<hello>d"
7474
|
7575
""".trimMargin(),
7676
)

backend/data/src/test/kotlin/io/tolgee/unit/formats/yaml/out/YamlFileExporterTest.kt

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ class YamlFileExporterTest {
1919
"cs.yaml",
2020
"""
2121
|---
22+
|indexed params: "I will be first {0}, {1}"
23+
|named params: "I will be first {param1}, {param2}"
24+
|this-is-array:
25+
|- object: "I will be first {icuParam, number}"
2226
|this:
2327
| is:
2428
| nested:
2529
| plural: "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}"
2630
| '[1]':
2731
| is:
2832
| collision: "Colission"
29-
|this-is-array:
30-
|- object: "I will be first {icuParam, number}"
31-
|indexed params: "I will be first {0}, {1}"
32-
|named params: "I will be first {param1}, {param2}"
3333
|
3434
""".trimMargin(),
3535
)
@@ -39,17 +39,17 @@ class YamlFileExporterTest {
3939
"cs.yaml",
4040
"""
4141
|---
42+
|indexed params: "I will be first {0}, {1}"
43+
|named params: "I will be first {param1}, {param2}"
44+
|this-is-array[1]:
45+
| object: "I will be first {icuParam, number}"
4246
|this:
4347
| is:
4448
| nested:
4549
| plural: "{count, plural, one {# den {icuParam}} few {# dny} other {# dní}}"
4650
|this[1]:
4751
| is:
4852
| collision: "Colission"
49-
|this-is-array[1]:
50-
| object: "I will be first {icuParam, number}"
51-
|indexed params: "I will be first {0}, {1}"
52-
|named params: "I will be first {param1}, {param2}"
5353
|
5454
""".trimMargin(),
5555
)
@@ -64,6 +64,10 @@ class YamlFileExporterTest {
6464
"cs.yaml",
6565
"""
6666
|---
67+
|indexed params: "I will be first %s, %s"
68+
|named params: "I will be first %{param1}, %{param2}"
69+
|this-is-array[1]:
70+
| object: "I will be first %<icuParam>d"
6771
|this:
6872
| is:
6973
| nested:
@@ -74,10 +78,6 @@ class YamlFileExporterTest {
7478
|this[1]:
7579
| is:
7680
| collision: "Colission"
77-
|this-is-array[1]:
78-
| object: "I will be first %<icuParam>d"
79-
|indexed params: "I will be first %s, %s"
80-
|named params: "I will be first %{param1}, %{param2}"
8181
|
8282
""".trimMargin(),
8383
)
@@ -92,12 +92,12 @@ class YamlFileExporterTest {
9292
"cs.yaml",
9393
"""
9494
|---
95+
|indexed params: "I will be first {0}, {1}"
96+
|named params: "I will be first {param1}, {param2}"
97+
|this-is-array[1].object: "I will be first {icuParam, number}"
9598
|this.is.nested.plural: "{count, plural, one {# den {icuParam}} few {# dny} other {#\
9699
| \ dní}}"
97100
|this[1].is.collision: "Colission"
98-
|this-is-array[1].object: "I will be first {icuParam, number}"
99-
|indexed params: "I will be first {0}, {1}"
100-
|named params: "I will be first {param1}, {param2}"
101101
|
102102
""".trimMargin(),
103103
)

0 commit comments

Comments
 (0)