Skip to content

Commit 320d87a

Browse files
bdshadowclaude
andauthored
fix: sort export translations by key name (Sentry TOLGEE-BACKEND-3ED) (#3470)
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"). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Export validation now detects and prevents collisions between plural key suffixes and existing keys during file export operations. Users receive a clear error message when conflicts are detected, specifying the involved keys and suffix to enable quick resolution. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1756b0f commit 320d87a

7 files changed

Lines changed: 80 additions & 2 deletions

File tree

backend/data/src/main/kotlin/io/tolgee/constants/Message.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ enum class Message {
325325
BRANCH_MERGE_CONFLICTS_NOT_RESOLVED,
326326
BRANCH_MERGE_ALREADY_MERGED,
327327
BRANCHING_NOT_ENABLED_FOR_PROJECT,
328+
EXPORT_KEY_PLURAL_SUFFIX_COLLISION,
328329
;
329330

330331
val code: String
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package io.tolgee.exceptions
2+
3+
import io.tolgee.constants.Message
4+
5+
class ExportCollidingKeysException(
6+
val pluralKey: String,
7+
val collidingKey: String,
8+
val suffix: String,
9+
) : BadRequestException(
10+
Message.EXPORT_KEY_PLURAL_SUFFIX_COLLISION,
11+
listOf(pluralKey, collidingKey, suffix),
12+
)

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ package io.tolgee.formats.genericStructuredFile.out
22

33
import com.fasterxml.jackson.databind.ObjectMapper
44
import io.tolgee.dtos.IExportParams
5+
import io.tolgee.exceptions.ExportCollidingKeysException
56
import io.tolgee.formats.ExportFormat
67
import io.tolgee.formats.ExportMessageFormat
8+
import io.tolgee.formats.formKeywords
79
import io.tolgee.formats.generic.IcuToGenericFormatMessageConvertor
810
import io.tolgee.formats.nestedStructureModel.StructureModelBuilder
911
import io.tolgee.formats.path.ObjectPathItem
@@ -39,11 +41,30 @@ class GenericStructuredFileExporter(
3941
}
4042

4143
private fun prepare() {
44+
if (pluralsViaSuffixes) {
45+
checkPluralSuffixCollisions()
46+
}
4247
translations.forEach { translation ->
4348
addTranslationToBuilder(translation)
4449
}
4550
}
4651

52+
private fun checkPluralSuffixCollisions() {
53+
val allKeyNames = translations.map { it.key.name }.toSet()
54+
translations.filter { it.key.isPlural }.forEach { translation ->
55+
for (suffix in formKeywords) {
56+
val suffixedName = "${translation.key.name}_$suffix"
57+
if (suffixedName in allKeyNames) {
58+
throw ExportCollidingKeysException(
59+
pluralKey = translation.key.name,
60+
collidingKey = suffixedName,
61+
suffix = suffix,
62+
)
63+
}
64+
}
65+
}
66+
}
67+
4768
private fun addTranslationToBuilder(translation: ExportTranslationView) {
4869
if (translation.key.isPlural) {
4970
addPluralTranslation(translation)

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package io.tolgee.unit.formats.i18next.out
22

33
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
44
import io.tolgee.dtos.request.export.ExportParams
5+
import io.tolgee.exceptions.ExportCollidingKeysException
56
import io.tolgee.formats.ExportFormat
67
import io.tolgee.formats.genericStructuredFile.out.CustomPrettyPrinter
78
import io.tolgee.formats.json.out.JsonFileExporter
@@ -11,6 +12,8 @@ import io.tolgee.service.export.dataProvider.ExportTranslationView
1112
import io.tolgee.unit.util.assertFile
1213
import io.tolgee.unit.util.getExported
1314
import io.tolgee.util.buildExportTranslationList
15+
import org.assertj.core.api.Assertions.assertThat
16+
import org.assertj.core.api.Assertions.assertThatThrownBy
1417
import org.junit.jupiter.api.Test
1518

1619
class I18nextFileExporterTest {
@@ -89,6 +92,38 @@ class I18nextFileExporterTest {
8992
)
9093
}
9194

95+
@Test
96+
fun `throws on plural suffix collision with existing key`() {
97+
val built =
98+
buildExportTranslationList {
99+
add(
100+
languageTag = "en",
101+
keyName = "profile.my-goals.plan",
102+
text = "{count, plural, other {Other}}",
103+
) {
104+
key.isPlural = true
105+
}
106+
add(
107+
languageTag = "en",
108+
keyName = "profile.my-goals.plan_other",
109+
text = "Some translation",
110+
)
111+
}
112+
val exporter =
113+
getExporter(
114+
built.translations,
115+
exportParams = ExportParams(format = ExportFormat.JSON_I18NEXT),
116+
)
117+
assertThatThrownBy { exporter.produceFiles() }
118+
.isInstanceOf(ExportCollidingKeysException::class.java)
119+
.satisfies({ ex ->
120+
val bre = ex as ExportCollidingKeysException
121+
assertThat(bre.pluralKey).isEqualTo("profile.my-goals.plan")
122+
assertThat(bre.collidingKey).isEqualTo("profile.my-goals.plan_other")
123+
assertThat(bre.suffix).isEqualTo("other")
124+
})
125+
}
126+
92127
private fun getTranslationWithColon(): MutableList<ExportTranslationView> {
93128
val built =
94129
buildExportTranslationList {

webapp/src/i18n/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,7 @@
739739
"cannot_change_your_own_access_tooltip": "Cannot revoke access to yourself",
740740
"cannot_delete_base_language_message": "Cannot delete base language",
741741
"cannot_delete_branch_with_children": "Cannot delete this branch because other branches were created from it.",
742+
"export_key_plural_suffix_collision": "Export failed: plural key \"{pluralKey}\" with suffix \"_{suffix}\" collides with existing key \"{collidingKey}\". Rename one of the keys to resolve the conflict.",
742743
"cannot_leave_project_with_organization_role_error_message": "Cannot leave project owned by the organization you are member of.",
743744
"cannot_modify_disabled_translation": "Cannot modify disabled translation",
744745
"cannot_modify_reviewed_translation": "Cannot modify reviewed translation",

webapp/src/service/apiSchema.generated.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2896,7 +2896,8 @@ export interface components {
28962896
| "branch_merge_revision_not_valid"
28972897
| "branch_merge_conflicts_not_resolved"
28982898
| "branch_merge_already_merged"
2899-
| "branching_not_enabled_for_project";
2899+
| "branching_not_enabled_for_project"
2900+
| "export_key_plural_suffix_collision";
29002901
params?: unknown[];
29012902
};
29022903
ExistenceEntityDescription: {
@@ -6272,7 +6273,8 @@ export interface components {
62726273
| "branch_merge_revision_not_valid"
62736274
| "branch_merge_conflicts_not_resolved"
62746275
| "branch_merge_already_merged"
6275-
| "branching_not_enabled_for_project";
6276+
| "branching_not_enabled_for_project"
6277+
| "export_key_plural_suffix_collision";
62766278
params?: unknown[];
62776279
success: boolean;
62786280
};

webapp/src/translationTools/useErrorTranslation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,12 @@ export function useErrorTranslation() {
213213
return t('operation_not_permitted_in_read_only_mode');
214214
case 'cannot_delete_branch_with_children':
215215
return t('cannot_delete_branch_with_children');
216+
case 'export_key_plural_suffix_collision':
217+
return t('export_key_plural_suffix_collision', {
218+
pluralKey: params?.[0] || '',
219+
collidingKey: params?.[1] || '',
220+
suffix: params?.[2] || '',
221+
});
216222
default:
217223
return code;
218224
}

0 commit comments

Comments
 (0)