Skip to content

Commit 7881986

Browse files
committed
feat: copy branch data service
1 parent 5609f43 commit 7881986

File tree

8 files changed

+330
-0
lines changed

8 files changed

+330
-0
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package io.tolgee.development.testDataBuilder.data
2+
3+
import io.tolgee.development.testDataBuilder.builders.ProjectBuilder
4+
import io.tolgee.development.testDataBuilder.builders.TestDataBuilder
5+
import io.tolgee.model.Language
6+
import io.tolgee.model.Project
7+
import io.tolgee.model.UserAccount
8+
import io.tolgee.model.branching.Branch
9+
import io.tolgee.model.enums.ProjectPermissionType
10+
11+
class BranchTranslationsTestData {
12+
lateinit var project: Project
13+
lateinit var en: Language
14+
lateinit var de: Language
15+
lateinit var user: UserAccount
16+
lateinit var mainBranch: Branch
17+
18+
val root: TestDataBuilder =
19+
TestDataBuilder().apply {
20+
val userAccountBuilder =
21+
addUserAccount {
22+
username = "ye"
23+
user = this
24+
}
25+
addProject {
26+
name = "Branch project"
27+
organizationOwner = userAccountBuilder.defaultOrganizationBuilder.self
28+
project = this
29+
}.build project@{
30+
addPermission {
31+
user = this@BranchTranslationsTestData.user
32+
type = ProjectPermissionType.MANAGE
33+
}
34+
en = addEnglish().self
35+
de = addGerman().self
36+
mainBranch = addBranch {
37+
name = "main"
38+
project = this@project.self
39+
isDefault = true
40+
isProtected = true
41+
}.build {
42+
(1..500).forEach {
43+
this@project.addBranchKey(it, "branched key", this@build.self)
44+
}
45+
}.self
46+
}.self
47+
}
48+
49+
fun generateBunchData(n: Int): ProjectBuilder {
50+
return root.data.projects[0].apply {
51+
(1..n).forEach {
52+
addBranchKey(it, "branched additional key", mainBranch)
53+
}
54+
}
55+
}
56+
57+
private fun ProjectBuilder.addBranchKey(num: Int, prefix: String, branch: Branch) {
58+
addKey {
59+
name = "$prefix $num"
60+
this.branch = branch
61+
}.build {
62+
addTranslation {
63+
language = en
64+
text = "I am key number $num - english"
65+
}
66+
addTranslation {
67+
language = de
68+
text = "I am key number $num - german"
69+
}
70+
}
71+
}
72+
}

backend/data/src/main/kotlin/io/tolgee/model/branching/Branch.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ class Branch(
5353
@ActivityLoggedProp
5454
var archivedAt: Date? = null,
5555

56+
@Column(name = "pending")
57+
var pending: Boolean = false,
58+
5659
) : StandardAuditModel() {
5760
@ManyToOne(optional = false, fetch = FetchType.LAZY)
5861
@JoinColumn(name = "project_id")

backend/data/src/main/kotlin/io/tolgee/repository/KeyRepository.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ import java.util.*
1616
@Repository
1717
@Lazy
1818
interface KeyRepository : JpaRepository<Key, Long> {
19+
20+
@Query(
21+
value = "select count(k.id) from key k where k.project_id = :projectId and k.branch_id = :branchId",
22+
nativeQuery = true
23+
)
24+
fun countByProjectAndBranch(projectId: Long, branchId: Long): Long
25+
1926
@Query(
2027
"""
2128
from Key k
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package io.tolgee.service.branching
2+
3+
import io.tolgee.model.branching.Branch
4+
5+
interface BranchCopyService {
6+
/**
7+
* Copies keys + translations (+ labels) from source branch into target branch within the same project.
8+
*/
9+
fun copy(projectId: Long, sourceBranch: Branch, targetBranch: Branch)
10+
}

backend/data/src/main/resources/db/changelog/schema.xml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4697,4 +4697,19 @@
46974697
<changeSet author="danielkrizan (generated)" id="1758015935878-10">
46984698
<addForeignKeyConstraint baseColumnNames="origin_branch_id" baseTableName="branch" constraintName="FKr1rv3tnqib0bpad7mjna0qvo0" deferrable="false" initiallyDeferred="false" referencedColumnNames="id" referencedTableName="branch" validate="true"/>
46994699
</changeSet>
4700+
<changeSet author="danielkrizan (generated)" id="1758202102054-1">
4701+
<addColumn tableName="branch">
4702+
<column name="pending" type="BOOLEAN"/>
4703+
</addColumn>
4704+
</changeSet>
4705+
<changeSet author="danielkrizan (generated)" id="1758202102054-2" runInTransaction="false">
4706+
<sql>
4707+
create unique index concurrently on public.key (project_id, "name", branch_id) where namespace_id is null;
4708+
</sql>
4709+
<sql>
4710+
create unique index concurrently on public.key (project_id, "name", branch_id, namespace_id) where namespace_id is not null;
4711+
</sql>
4712+
<dropIndex indexName="key_project_id_name_idx" tableName="key"/>
4713+
<dropIndex indexName="key_project_id_name_namespace_id_idx" tableName="key"/>
4714+
</changeSet>
47004715
</databaseChangeLog>
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package io.tolgee.ee.service.branching
2+
3+
import io.tolgee.model.branching.Branch
4+
import io.tolgee.service.branching.BranchCopyService
5+
import jakarta.persistence.EntityManager
6+
import jakarta.transaction.Transactional
7+
import org.springframework.stereotype.Service
8+
9+
@Service
10+
class BranchCopyServiceSql(
11+
private val entityManager: EntityManager,
12+
) : BranchCopyService {
13+
14+
/**
15+
* Copies keys and its related entities from a source branch to target branch
16+
* - translations
17+
* - translation labels
18+
* If sourceBranch is a default, keys with NULL branch_id are also treated as part of the source
19+
*/
20+
@Transactional
21+
override fun copy(projectId: Long, sourceBranch: Branch, targetBranch: Branch) {
22+
require(sourceBranch.id != targetBranch.id) { "Source and target branch must differ" }
23+
24+
copyKeys(projectId, sourceBranch, targetBranch)
25+
copyTranslations(projectId, sourceBranch, targetBranch)
26+
copyTranslationLabels(projectId, sourceBranch, targetBranch)
27+
}
28+
29+
private fun copyKeys(projectId: Long, sourceBranch: Branch, targetBranch: Branch) {
30+
val sql = """
31+
with source_keys as (
32+
select k.id as old_id, k.name, k.namespace_id, k.is_plural, k.plural_arg_name
33+
from key k
34+
where k.project_id = :projectId
35+
and (
36+
(:sourceIsDefault and (k.branch_id = :sourceBranchId or k.branch_id is null))
37+
or (not :sourceIsDefault and k.branch_id = :sourceBranchId)
38+
)
39+
), existing_target as (
40+
select tk.id, tk.name, tk.namespace_id
41+
from key tk
42+
where tk.project_id = :projectId and tk.branch_id = :targetBranchId
43+
), to_insert as (
44+
select sk.* from source_keys sk
45+
left join existing_target et on et.name = sk.name and coalesce(et.namespace_id,0) = coalesce(sk.namespace_id,0)
46+
where et.id is null
47+
)
48+
insert into key (id, name, project_id, namespace_id, branch_id, is_plural, plural_arg_name, created_at, updated_at)
49+
select nextval('hibernate_sequence') as id,
50+
ti.name, :projectId, ti.namespace_id, :targetBranchId, ti.is_plural, ti.plural_arg_name, now(), now()
51+
from to_insert ti
52+
"""
53+
entityManager.createNativeQuery(sql)
54+
.setParameter("projectId", projectId)
55+
.setParameter("sourceBranchId", sourceBranch.id)
56+
.setParameter("sourceIsDefault", sourceBranch.isDefault)
57+
.setParameter("targetBranchId", targetBranch.id)
58+
.executeUpdate()
59+
}
60+
61+
private fun copyTranslations(
62+
projectId: Long,
63+
sourceBranch: Branch,
64+
targetBranch: Branch,
65+
) {
66+
val sql = """
67+
with source_keys as (
68+
select k.id as old_id, k.name, k.namespace_id
69+
from key k
70+
where k.project_id = :projectId
71+
and (
72+
(:sourceIsDefault and (k.branch_id = :sourceBranchId or k.branch_id is null))
73+
or (not :sourceIsDefault and k.branch_id = :sourceBranchId)
74+
)
75+
), target_keys as (
76+
select tk.id, tk.name, tk.namespace_id
77+
from key tk
78+
where tk.project_id = :projectId and tk.branch_id = :targetBranchId
79+
), key_id_map as (
80+
select sk.old_id as old_key_id, tk.id as new_key_id
81+
from source_keys sk
82+
join target_keys tk on tk.name = sk.name and coalesce(tk.namespace_id,0) = coalesce(sk.namespace_id,0)
83+
)
84+
insert into translation (
85+
id, text, key_id, language_id, state, auto, mt_provider, word_count, character_count, outdated, created_at, updated_at
86+
)
87+
select nextval('hibernate_sequence') as id,
88+
t.text, m.new_key_id, t.language_id, t.state, t.auto, t.mt_provider,
89+
t.word_count, t.character_count, t.outdated, now(), now()
90+
from translation t
91+
join key_id_map m on m.old_key_id = t.key_id
92+
left join translation existing on existing.key_id = m.new_key_id and existing.language_id = t.language_id
93+
where existing.id is null
94+
"""
95+
entityManager.createNativeQuery(sql)
96+
.setParameter("projectId", projectId)
97+
.setParameter("sourceBranchId", sourceBranch.id)
98+
.setParameter("sourceIsDefault", sourceBranch.isDefault)
99+
.setParameter("targetBranchId", targetBranch.id)
100+
.executeUpdate()
101+
}
102+
103+
private fun copyTranslationLabels(
104+
projectId: Long,
105+
sourceBranch: Branch,
106+
targetBranch: Branch,
107+
) {
108+
val sql = """
109+
insert into translation_label (translation_id, label_id)
110+
select tgt_t.id, tl.label_id
111+
from translation src_t
112+
join key sk on sk.id = src_t.key_id and sk.project_id = :projectId
113+
join key tk on tk.project_id = sk.project_id
114+
and tk.branch_id = :targetBranchId
115+
and tk.name = sk.name
116+
and coalesce(tk.namespace_id,0) = coalesce(sk.namespace_id,0)
117+
join translation tgt_t on tgt_t.key_id = tk.id and tgt_t.language_id = src_t.language_id
118+
join translation_label tl on tl.translation_id = src_t.id
119+
where (
120+
(:sourceIsDefault and (sk.branch_id = :sourceBranchId or sk.branch_id is null))
121+
or (not :sourceIsDefault and sk.branch_id = :sourceBranchId)
122+
)
123+
and not exists (
124+
select 1 from translation_label existing where existing.translation_id = tgt_t.id and existing.label_id = tl.label_id
125+
)
126+
"""
127+
entityManager.createNativeQuery(sql)
128+
.setParameter("projectId", projectId)
129+
.setParameter("sourceBranchId", sourceBranch.id)
130+
.setParameter("sourceIsDefault", sourceBranch.isDefault)
131+
.setParameter("targetBranchId", targetBranch.id)
132+
.executeUpdate()
133+
}
134+
}

ee/backend/app/src/main/kotlin/io/tolgee/ee/service/branching/BranchServiceImpl.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import io.tolgee.exceptions.BadRequestException
77
import io.tolgee.exceptions.PermissionException
88
import io.tolgee.model.Project
99
import io.tolgee.model.branching.Branch
10+
import io.tolgee.service.branching.BranchCopyService
1011
import io.tolgee.service.branching.BranchService
1112
import jakarta.persistence.EntityManager
1213
import jakarta.transaction.Transactional
@@ -21,6 +22,7 @@ class BranchServiceImpl(
2122
private val branchRepository: BranchRepository,
2223
private val currentDateProvider: CurrentDateProvider,
2324
private val entityManager: EntityManager,
25+
private val branchCopyService: BranchCopyService,
2426
) : BranchService {
2527
override fun getAllBranches(projectId: Long, page: Pageable, search: String?): Page<Branch> {
2628
return branchRepository.getAllProjectBranches(projectId, page, search)
@@ -31,10 +33,22 @@ class BranchServiceImpl(
3133
val originBranch = branchRepository.findById(originBranchId)
3234
.orElseThrow { BadRequestException(Message.ORIGIN_BRANCH_NOT_FOUND) }
3335

36+
if (originBranch.project.id != projectId) {
37+
throw BadRequestException(Message.ORIGIN_BRANCH_NOT_FOUND)
38+
}
39+
3440
val branch = createBranch(projectId, name).also {
3541
it.originBranch = originBranch
42+
it.pending = true
3643
}
3744
branchRepository.save(branch)
45+
46+
val sourceIsDefault = originBranch.isDefault
47+
branchCopyService.copy(projectId, originBranch, branch)
48+
49+
branch.pending = false
50+
branchRepository.save(branch)
51+
3852
return branch
3953
}
4054

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package io.tolgee.ee.api.v2.controllers.branching
2+
3+
import io.tolgee.ProjectAuthControllerTest
4+
import io.tolgee.development.testDataBuilder.data.BranchTranslationsTestData
5+
import io.tolgee.ee.repository.BranchRepository
6+
import io.tolgee.fixtures.andAssertThatJson
7+
import io.tolgee.fixtures.andIsOk
8+
import io.tolgee.testing.annotations.ProjectJWTAuthTestMethod
9+
import io.tolgee.testing.assert
10+
import org.junit.jupiter.api.BeforeEach
11+
import org.junit.jupiter.api.Test
12+
import org.springframework.beans.factory.annotation.Autowired
13+
import org.springframework.data.repository.findByIdOrNull
14+
import org.springframework.test.web.servlet.ResultActions
15+
import kotlin.system.measureTimeMillis
16+
17+
@Suppress("SpringJavaInjectionPointsAutowiringInspection")
18+
class BranchCopyIntegrationTest : ProjectAuthControllerTest("/v2/projects/") {
19+
20+
lateinit var testData: BranchTranslationsTestData
21+
22+
@Autowired
23+
lateinit var branchRepository: BranchRepository
24+
25+
@BeforeEach
26+
fun setup() {
27+
testData = BranchTranslationsTestData()
28+
projectSupplier = { testData.project }
29+
testDataService.saveTestData(testData.root)
30+
userAccount = testData.user
31+
}
32+
33+
@Test
34+
@ProjectJWTAuthTestMethod
35+
fun `copies keys and translations to new branch`() {
36+
val projectId = testData.project.id
37+
38+
performBranchCreation().andIsOk.andAssertThatJson {
39+
node("name").isEqualTo("feature-x")
40+
node("active").isEqualTo(true)
41+
}
42+
43+
val newBranchId = branchRepository.findByProjectIdAndName(projectId, "feature-x")!!.id
44+
val newBranchKeyCount = keyRepository.countByProjectAndBranch(projectId, newBranchId)
45+
46+
newBranchKeyCount.assert.isEqualTo(500)
47+
48+
// branch should be ready
49+
val branch = branchRepository.findByIdOrNull(newBranchId)!!
50+
branch.pending.assert.isFalse()
51+
}
52+
53+
@Test
54+
@ProjectJWTAuthTestMethod
55+
fun `copying a lot data is not slow`() {
56+
val data = testData.generateBunchData(2000)
57+
testDataService.saveTestData { data.build {} }
58+
var response: ResultActions
59+
val time = measureTimeMillis {
60+
response = performBranchCreation()
61+
}
62+
response.andIsOk
63+
time.assert.isLessThan(3000)
64+
}
65+
66+
private fun performBranchCreation(): ResultActions {
67+
return performProjectAuthPost(
68+
"branches",
69+
mapOf(
70+
"name" to "feature-x",
71+
"originBranchId" to testData.mainBranch.id,
72+
)
73+
)
74+
}
75+
}

0 commit comments

Comments
 (0)