aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorClaire Villard <claire.villard@sonarsource.com>2024-10-01 18:42:45 +0200
committersonartech <sonartech@sonarsource.com>2024-10-14 20:03:02 +0000
commitd7c1632113c8c1fe15766e3728a831a146c1de21 (patch)
tree4841a280c7c4f2c88511a31b3747decc50a350f1 /server
parentf135567f2bc832f2a24c4cf3d32d0b53d6f22cf8 (diff)
downloadsonarqube-d7c1632113c8c1fe15766e3728a831a146c1de21.tar.gz
sonarqube-d7c1632113c8c1fe15766e3728a831a146c1de21.zip
SONAR-23213 'api/system/migrate_measures' endpoint
Diffstat (limited to 'server')
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchDao.java23
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchMapper.java6
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioDao.java23
-rw-r--r--server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioMapper.java6
-rw-r--r--server/sonar-db-dao/src/main/resources/org/sonar/db/component/BranchMapper.xml41
-rw-r--r--server/sonar-db-dao/src/main/resources/org/sonar/db/portfolio/PortfolioMapper.xml46
-rw-r--r--server/sonar-db-dao/src/test/java/org/sonar/db/component/BranchDaoTest.java93
-rw-r--r--server/sonar-db-dao/src/test/java/org/sonar/db/portfolio/PortfolioDaoTest.java82
-rw-r--r--server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/AbstractMigrateLiveMeasuresToMeasures.java196
-rw-r--r--server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasures.java30
-rw-r--r--server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasures.java30
-rw-r--r--server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/DataChange.java4
-rw-r--r--server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest.java180
-rw-r--r--server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest.java178
-rw-r--r--server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest/schema.sql63
-rw-r--r--server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest/schema.sql64
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/MigrateMeasuresAction.java153
-rw-r--r--server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/SystemWsModule.java6
-rw-r--r--server/sonar-webserver-webapi/src/main/resources/org/sonar/server/platform/ws/example-migrate_measures.json8
-rw-r--r--server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/MigrateMeasuresActionTest.java233
-rw-r--r--server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/SystemWsModuleTest.java2
21 files changed, 1411 insertions, 56 deletions
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchDao.java
index 9bc71dc3534..402e09abe5f 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchDao.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchDao.java
@@ -19,6 +19,7 @@
*/
package org.sonar.db.component;
+import com.google.common.annotations.VisibleForTesting;
import java.util.Collection;
import java.util.LinkedList;
import java.util.List;
@@ -136,6 +137,24 @@ public class BranchDao implements Dao {
return mapper(session).countByNeedIssueSync(needIssueSync);
}
+ public List<String> selectUuidsWithMeasuresMigratedFalse(DbSession session, int limit) {
+ return mapper(session).selectUuidsWithMeasuresMigratedFalse(limit);
+ }
+
+ public int countByMeasuresMigratedFalse(DbSession session) {
+ return mapper(session).countByMeasuresMigratedFalse();
+ }
+
+ public long updateMeasuresMigrated(DbSession dbSession, String branchUuid, boolean measuresMigrated) {
+ long now = system2.now();
+ return mapper(dbSession).updateMeasuresMigrated(branchUuid, measuresMigrated, now);
+ }
+
+ @VisibleForTesting
+ boolean isMeasuresMigrated(DbSession dbSession, String uuid) {
+ return mapper(dbSession).isMeasuresMigrated(uuid);
+ }
+
public int countAll(DbSession session) {
return mapper(session).countAll();
}
@@ -178,8 +197,4 @@ public class BranchDao implements Dao {
return false;
}
- public long updateMeasuresMigrated(DbSession dbSession, String branchUuid, boolean measuresMigrated) {
- long now = system2.now();
- return mapper(dbSession).updateMeasuresMigrated(branchUuid, measuresMigrated, now);
- }
}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchMapper.java
index 24cdbacc68c..95adbb62244 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchMapper.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchMapper.java
@@ -73,4 +73,10 @@ public interface BranchMapper {
short doAnyOfComponentsNeedIssueSync(@Param("componentKeys") List<String> components);
int updateMeasuresMigrated(@Param("uuid") String uuid, @Param("measuresMigrated") boolean measuresMigrated, @Param("now") long now);
+
+ boolean isMeasuresMigrated(String uuid);
+
+ List<String> selectUuidsWithMeasuresMigratedFalse(int limit);
+
+ int countByMeasuresMigratedFalse();
}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioDao.java b/server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioDao.java
index cbcfd25b3c6..0a6863b6fe6 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioDao.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioDao.java
@@ -19,6 +19,7 @@
*/
package org.sonar.db.portfolio;
+import com.google.common.annotations.VisibleForTesting;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -253,6 +254,24 @@ public class PortfolioDao implements Dao {
mapper(dbSession).deleteBranch(portfolioUuid, projectUuid, branchUuid);
}
+ public long updateMeasuresMigrated(DbSession dbSession, String branchUuid, boolean measuresMigrated) {
+ long now = system2.now();
+ return mapper(dbSession).updateMeasuresMigrated(branchUuid, measuresMigrated, now);
+ }
+
+ public List<String> selectUuidsWithMeasuresMigratedFalse(DbSession session, int limit) {
+ return mapper(session).selectUuidsWithMeasuresMigratedFalse(limit);
+ }
+
+ public int countByMeasuresMigratedFalse(DbSession session) {
+ return mapper(session).countByMeasuresMigratedFalse();
+ }
+
+ @VisibleForTesting
+ boolean isMeasuresMigrated(DbSession dbSession, String uuid) {
+ return mapper(dbSession).isMeasuresMigrated(uuid);
+ }
+
/*
* Utils
*/
@@ -268,8 +287,4 @@ public class PortfolioDao implements Dao {
return portfolioDto.isRoot() ? Qualifiers.VIEW : Qualifiers.SUBVIEW;
}
- public long updateMeasuresMigrated(DbSession dbSession, String branchUuid, boolean measuresMigrated) {
- long now = system2.now();
- return mapper(dbSession).updateMeasuresMigrated(branchUuid, measuresMigrated, now);
- }
}
diff --git a/server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioMapper.java b/server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioMapper.java
index 2e273acc6d7..1dbb66749bb 100644
--- a/server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioMapper.java
+++ b/server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioMapper.java
@@ -113,4 +113,10 @@ public interface PortfolioMapper {
List<PortfolioDto> selectRootOfReferencersToAppBranch(@Param("appUuid") String appUuid, @Param("appBranchKey") String appBranchKey);
int updateMeasuresMigrated(@Param("uuid") String uuid, @Param("measuresMigrated") boolean measuresMigrated, @Param("now") long now);
+
+ List<String> selectUuidsWithMeasuresMigratedFalse(int limit);
+
+ int countByMeasuresMigratedFalse();
+
+ boolean isMeasuresMigrated(@Param("uuid") String branchUuid);
}
diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/component/BranchMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/component/BranchMapper.xml
index eafb0b008cb..238da88d1bd 100644
--- a/server/sonar-db-dao/src/main/resources/org/sonar/db/component/BranchMapper.xml
+++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/component/BranchMapper.xml
@@ -256,4 +256,45 @@
where
uuid = #{uuid, jdbcType=VARCHAR}
</update>
+
+ <select id="isMeasuresMigrated" resultType="boolean">
+ select
+ pb.measures_migrated
+ from project_branches pb
+ where
+ pb.uuid = #{uuid, jdbcType=VARCHAR}
+ </select>
+
+ <select id="selectUuidsWithMeasuresMigratedFalse" resultType="java.lang.String" parameterType="int">
+ SELECT pb.uuid
+ FROM project_branches pb
+ WHERE pb.measures_migrated = ${_false}
+ ORDER BY pb.uuid ASC
+ LIMIT #{limit}
+ </select>
+
+ <select id="selectUuidsWithMeasuresMigratedFalse" resultType="java.lang.String" parameterType="int" databaseId="oracle">
+ SELECT * FROM (
+ SELECT pb.uuid
+ FROM project_branches pb
+ WHERE pb.measures_migrated = ${_false}
+ ORDER BY pb.uuid ASC
+ ) WHERE rownum &lt;= #{limit}
+ </select>
+
+ <select id="selectUuidsWithMeasuresMigratedFalse" resultType="java.lang.String" parameterType="int" databaseId="mssql">
+ SELECT top(#{limit, jdbcType=INTEGER}) pb.uuid
+ FROM project_branches pb
+ WHERE pb.measures_migrated = ${_false}
+ ORDER BY pb.uuid ASC
+ </select>
+
+ <select id="countByMeasuresMigratedFalse" resultType="int">
+ select
+ count(pb.uuid)
+ from project_branches pb
+ where
+ pb.measures_migrated = ${_false}
+ </select>
+
</mapper>
diff --git a/server/sonar-db-dao/src/main/resources/org/sonar/db/portfolio/PortfolioMapper.xml b/server/sonar-db-dao/src/main/resources/org/sonar/db/portfolio/PortfolioMapper.xml
index 832fc96bb4a..db973bc0b50 100644
--- a/server/sonar-db-dao/src/main/resources/org/sonar/db/portfolio/PortfolioMapper.xml
+++ b/server/sonar-db-dao/src/main/resources/org/sonar/db/portfolio/PortfolioMapper.xml
@@ -544,6 +544,50 @@
measures_migrated = #{measuresMigrated, jdbcType=BOOLEAN},
updated_at = #{now, jdbcType=BIGINT}
where
- uuid = #{uuid, jdbcType=VARCHAR}
+ root_uuid = #{uuid, jdbcType=VARCHAR}
</update>
+
+ <select id="selectUuidsWithMeasuresMigratedFalse" resultType="java.lang.String" parameterType="int">
+ SELECT p.uuid
+ FROM portfolios p
+ WHERE p.measures_migrated = ${_false}
+ AND p.parent_uuid is null
+ ORDER BY p.uuid ASC
+ LIMIT #{limit}
+ </select>
+
+ <select id="selectUuidsWithMeasuresMigratedFalse" resultType="java.lang.String" parameterType="int" databaseId="oracle">
+ SELECT * FROM (
+ SELECT p.uuid
+ FROM portfolios p
+ WHERE p.measures_migrated = ${_false}
+ AND p.parent_uuid is null
+ ORDER BY p.uuid ASC
+ ) WHERE rownum &lt;= #{limit}
+ </select>
+
+ <select id="selectUuidsWithMeasuresMigratedFalse" resultType="java.lang.String" parameterType="int" databaseId="mssql">
+ SELECT top(#{limit, jdbcType=INTEGER}) p.uuid
+ FROM portfolios p
+ WHERE p.measures_migrated = ${_false}
+ AND p.parent_uuid is null
+ ORDER BY p.uuid ASC
+ </select>
+
+ <select id="countByMeasuresMigratedFalse" resultType="int">
+ select
+ count(p.uuid)
+ from portfolios p
+ where
+ p.measures_migrated = ${_false}
+ and p.parent_uuid is null
+ </select>
+
+ <select id="isMeasuresMigrated" resultType="boolean">
+ select
+ p.measures_migrated
+ from portfolios p
+ where
+ p.uuid = #{uuid, jdbcType=VARCHAR}
+ </select>
</mapper>
diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/component/BranchDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/component/BranchDaoTest.java
index 8f42fc67056..f11f90f271c 100644
--- a/server/sonar-db-dao/src/test/java/org/sonar/db/component/BranchDaoTest.java
+++ b/server/sonar-db-dao/src/test/java/org/sonar/db/component/BranchDaoTest.java
@@ -39,13 +39,11 @@ import org.sonar.api.impl.utils.TestSystem2;
import org.sonar.api.utils.System2;
import org.sonar.db.DbSession;
import org.sonar.db.DbTester;
-import org.sonar.db.dialect.Oracle;
import org.sonar.db.metric.MetricDto;
import org.sonar.db.project.ProjectDto;
import org.sonar.db.protobuf.DbProjectBranches;
import org.sonar.server.platform.db.migration.adhoc.AddMeasuresMigratedColumnToProjectBranchesTable;
-import static java.lang.String.format;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
@@ -656,6 +654,70 @@ public class BranchDaoTest {
}
@Test
+ public void selectUuidsWithMeasuresMigratedFalse() throws SQLException {
+ createMeasuresMigratedColumn();
+
+ // master branch with flag set to false
+ ComponentDto notMigratedProject = db.components().insertPrivateProject();
+ // branches & PRs
+ ComponentDto migratedBranch = db.components().insertProjectBranch(notMigratedProject, b -> b.setBranchType(BRANCH));
+ ComponentDto notMigratedBranch = db.components().insertProjectBranch(notMigratedProject, b -> b.setBranchType(BranchType.BRANCH));
+ ComponentDto migratedPR = db.components().insertProjectBranch(notMigratedProject, b -> b.setBranchType(BranchType.PULL_REQUEST));
+ ComponentDto notMigratedPR = db.components().insertProjectBranch(notMigratedProject, b -> b.setBranchType(PULL_REQUEST));
+
+ db.getDbClient().branchDao().updateMeasuresMigrated(dbSession, migratedBranch.branchUuid(), true);
+ db.getDbClient().branchDao().updateMeasuresMigrated(dbSession, notMigratedBranch.branchUuid(), false);
+ db.getDbClient().branchDao().updateMeasuresMigrated(dbSession, migratedPR.branchUuid(), true);
+
+ assertThat(underTest.selectUuidsWithMeasuresMigratedFalse(dbSession, 10))
+ .hasSize(3)
+ .containsOnly(notMigratedProject.branchUuid(), notMigratedBranch.branchUuid(), notMigratedPR.branchUuid());
+
+ assertThat(underTest.selectUuidsWithMeasuresMigratedFalse(dbSession, 1))
+ .hasSize(1)
+ .containsOnly(notMigratedProject.branchUuid());
+ }
+
+ @Test
+ public void countByMeasuresMigratedFalse() throws SQLException {
+ createMeasuresMigratedColumn();
+
+ // master branch with flag set to false
+ ComponentDto project = db.components().insertPrivateProject();
+ // branches & PRs
+ ComponentDto branch1 = db.components().insertProjectBranch(project, b -> b.setBranchType(BRANCH));
+ ComponentDto branch2 = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.BRANCH));
+ ComponentDto branch3 = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.PULL_REQUEST));
+ db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.PULL_REQUEST));
+
+ db.getDbClient().branchDao().updateMeasuresMigrated(dbSession, branch1.branchUuid(), true);
+ db.getDbClient().branchDao().updateMeasuresMigrated(dbSession, branch2.branchUuid(), false);
+ db.getDbClient().branchDao().updateMeasuresMigrated(dbSession, branch3.branchUuid(), true);
+
+ assertThat(underTest.countByMeasuresMigratedFalse(dbSession)).isEqualTo(3);
+ }
+
+ @Test
+ public void updateMeasuresMigrated() throws SQLException {
+ createMeasuresMigratedColumn();
+
+ ComponentDto project = db.components().insertPrivateProject();
+ String uuid1 = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.BRANCH)).uuid();
+ String uuid2 = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.BRANCH)).uuid();
+
+ underTest.updateMeasuresMigrated(dbSession, uuid1, true);
+ underTest.updateMeasuresMigrated(dbSession, uuid2, false);
+
+ assertThat(underTest.isMeasuresMigrated(dbSession, uuid1)).isTrue();
+ assertThat(underTest.isMeasuresMigrated(dbSession, uuid2)).isFalse();
+ }
+
+ private void createMeasuresMigratedColumn() throws SQLException {
+ AddMeasuresMigratedColumnToProjectBranchesTable migration = new AddMeasuresMigratedColumnToProjectBranchesTable(db.getDbClient().getDatabase());
+ migration.execute();
+ }
+
+ @Test
public void countAll() {
assertThat(underTest.countAll(dbSession)).isZero();
@@ -780,31 +842,4 @@ public class BranchDaoTest {
assertThat(underTest.doAnyOfComponentsNeedIssueSync(dbSession, componentKeys)).isTrue();
}
-
- @Test
- public void updateMeasuresMigrated() throws SQLException {
- new AddMeasuresMigratedColumnToProjectBranchesTable(db.getDbClient().getDatabase()).execute();
-
- ComponentDto project = db.components().insertPrivateProject();
- String uuid1 = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.BRANCH)).uuid();
- String uuid2 = db.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.BRANCH)).uuid();
-
- underTest.updateMeasuresMigrated(dbSession, uuid1, true);
- underTest.updateMeasuresMigrated(dbSession, uuid2, false);
-
- assertThat(getMeasuresMigrated(uuid1)).isTrue();
- assertThat(getMeasuresMigrated(uuid2)).isFalse();
- }
-
- private boolean getMeasuresMigrated(String uuid1) {
- List<Map<String, Object>> select = db.select(dbSession,
- format("select measures_migrated as \"MIGRATED\" from project_branches where uuid = '%s'", uuid1));
-
- assertThat(select).hasSize(1);
- Object value = select.get(0).get("MIGRATED");
- if (db.getDbClient().getDatabase().getDialect().getId().equals(Oracle.ID)) {
- return (long) value == 1;
- }
- return (boolean) value;
- }
}
diff --git a/server/sonar-db-dao/src/test/java/org/sonar/db/portfolio/PortfolioDaoTest.java b/server/sonar-db-dao/src/test/java/org/sonar/db/portfolio/PortfolioDaoTest.java
index ae12b1b059d..9ea18c73c0e 100644
--- a/server/sonar-db-dao/src/test/java/org/sonar/db/portfolio/PortfolioDaoTest.java
+++ b/server/sonar-db-dao/src/test/java/org/sonar/db/portfolio/PortfolioDaoTest.java
@@ -20,8 +20,6 @@
package org.sonar.db.portfolio;
import java.sql.SQLException;
-import java.util.List;
-import java.util.Map;
import java.util.Set;
import org.junit.Rule;
import org.junit.Test;
@@ -32,12 +30,10 @@ import org.sonar.db.DbSession;
import org.sonar.db.DbTester;
import org.sonar.db.audit.AuditPersister;
import org.sonar.db.component.BranchDto;
-import org.sonar.db.dialect.Oracle;
import org.sonar.db.project.ApplicationProjectDto;
import org.sonar.db.project.ProjectDto;
import org.sonar.server.platform.db.migration.adhoc.AddMeasuresMigratedColumnToPortfoliosTable;
-import static java.lang.String.format;
import static java.util.Collections.emptySet;
import static java.util.Collections.singleton;
import static org.assertj.core.api.Assertions.assertThat;
@@ -715,20 +711,76 @@ public class PortfolioDaoTest {
portfolioDao.updateMeasuresMigrated(session, portfolio1.getUuid(), true);
portfolioDao.updateMeasuresMigrated(session, portfolio2.getUuid(), false);
- assertThat(getMeasuresMigrated(portfolio1.getUuid())).isTrue();
- assertThat(getMeasuresMigrated(portfolio2.getUuid())).isFalse();
+ assertThat(portfolioDao.isMeasuresMigrated(session, portfolio1.getUuid())).isTrue();
+ assertThat(portfolioDao.isMeasuresMigrated(session, portfolio2.getUuid())).isFalse();
}
- private boolean getMeasuresMigrated(String uuid1) {
- List<Map<String, Object>> select = db.select(session,
- format("select measures_migrated as \"MIGRATED\" from portfolios where uuid = '%s'", uuid1));
+ @Test
+ public void selectUuidsWithMeasuresMigratedFalse() throws SQLException {
+ createMeasuresMigratedColumn();
+
+ PortfolioDto migratedRoot = db.components().insertPrivatePortfolioDto();
+ PortfolioDto migratedChild1 = addPortfolio(migratedRoot);
+ PortfolioDto notMigratedRoot = db.components().insertPrivatePortfolioDto();
+ PortfolioDto notMigratedChild = addPortfolio(notMigratedRoot);
+ PortfolioDto notMigratedRoot2 = db.components().insertPrivatePortfolioDto();
+
+ // a parent portfolio and its children are expected to have the same migration status
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, migratedRoot.getUuid(), true);
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, migratedChild1.getUuid(), true);
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, notMigratedRoot.getUuid(), false);
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, notMigratedChild.getUuid(), false);
+
+ assertThat(portfolioDao.selectUuidsWithMeasuresMigratedFalse(session, 10))
+ .hasSize(2)
+ .containsOnly(notMigratedRoot.getUuid(), notMigratedRoot2.getUuid());
+
+ assertThat(portfolioDao.selectUuidsWithMeasuresMigratedFalse(session, 1))
+ .hasSize(1)
+ .containsOnly(notMigratedRoot.getUuid());
+ }
+
+ @Test
+ public void countByMeasuresMigratedFalse() throws SQLException {
+ createMeasuresMigratedColumn();
+
+ PortfolioDto root = db.components().insertPrivatePortfolioDto();
+ PortfolioDto child1 = addPortfolio(root);
+ PortfolioDto root2 = db.components().insertPrivatePortfolioDto();
+ PortfolioDto child2 = addPortfolio(root2);
+ PortfolioDto root3 = db.components().insertPrivatePortfolioDto();
+ db.components().insertPrivatePortfolioDto();
+
+ // a parent portfolio and its children are expected to have the same migration status
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, root.getUuid(), true);
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, child1.getUuid(), true);
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, root2.getUuid(), false);
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, child2.getUuid(), false);
+ db.getDbClient().portfolioDao().updateMeasuresMigrated(session, root3.getUuid(), false);
+
+ assertThat(portfolioDao.countByMeasuresMigratedFalse(session)).isEqualTo(3);
+ }
+
+ @Test
+ public void updateMeasuresMigrated() throws SQLException {
+ createMeasuresMigratedColumn();
+
+ PortfolioDto portfolio1 = db.components().insertPrivatePortfolioDto("name1");
+ PortfolioDto portfolio2 = db.components().insertPrivatePortfolioDto("name2");
+ PortfolioDto portfolio3 = db.components().insertPrivatePortfolioDto("name3",
+ p -> p.setRootUuid(portfolio1.getUuid()).setParentUuid(portfolio1.getUuid()));
+
+ portfolioDao.updateMeasuresMigrated(session, portfolio1.getUuid(), true);
+ portfolioDao.updateMeasuresMigrated(session, portfolio2.getUuid(), false);
+
+ assertThat(portfolioDao.isMeasuresMigrated(session, portfolio1.getUuid())).isTrue();
+ assertThat(portfolioDao.isMeasuresMigrated(session, portfolio2.getUuid())).isFalse();
+ assertThat(portfolioDao.isMeasuresMigrated(session, portfolio3.getUuid())).isTrue();
+ }
- assertThat(select).hasSize(1);
- Object value = select.get(0).get("MIGRATED");
- if (db.getDbClient().getDatabase().getDialect().getId().equals(Oracle.ID)) {
- return (long) value == 1;
- }
- return (boolean) value;
+ private void createMeasuresMigratedColumn() throws SQLException {
+ AddMeasuresMigratedColumnToPortfoliosTable migration = new AddMeasuresMigratedColumnToPortfoliosTable(db.getDbClient().getDatabase());
+ migration.execute();
}
private PortfolioDto addPortfolio(PortfolioDto parent) {
diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/AbstractMigrateLiveMeasuresToMeasures.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/AbstractMigrateLiveMeasuresToMeasures.java
new file mode 100644
index 00000000000..5135d7aa859
--- /dev/null
+++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/AbstractMigrateLiveMeasuresToMeasures.java
@@ -0,0 +1,196 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.adhoc;
+
+import com.google.gson.Gson;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.annotation.Nullable;
+import org.apache.commons.codec.digest.MurmurHash3;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.sonar.api.utils.System2;
+import org.sonar.db.Database;
+import org.sonar.server.platform.db.migration.step.DataChange;
+import org.sonar.server.platform.db.migration.step.MassUpdate;
+import org.sonar.server.platform.db.migration.step.Select;
+import org.sonar.server.platform.db.migration.step.SqlStatement;
+import org.sonar.server.platform.db.migration.step.Upsert;
+
+import static java.lang.String.format;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+public abstract class AbstractMigrateLiveMeasuresToMeasures extends DataChange {
+ private static final Logger LOGGER = LoggerFactory.getLogger(AbstractMigrateLiveMeasuresToMeasures.class);
+
+ private static final Set<String> TEXT_VALUE_TYPES = Set.of("STRING", "LEVEL", "DATA", "DISTRIB");
+ private static final Gson GSON = new Gson();
+
+ private static final String SELECT_QUERY = """
+ SELECT lm.component_uuid,
+ m.name,
+ m.val_type,
+ lm.value,
+ lm.text_value,
+ lm.measure_data
+ FROM live_measures lm
+ INNER JOIN metrics m ON m.uuid = lm.metric_uuid
+ WHERE lm.project_uuid = ?
+ ORDER BY lm.component_uuid
+ """;
+
+ private static final String INSERT_QUERY = """
+ insert into measures (component_uuid, branch_uuid, json_value, json_value_hash, created_at, updated_at)
+ values ( ?, ?, ?, ?, ?, ?)
+ """;
+
+ private final String tableName;
+ private final String item;
+ private final System2 system2;
+ private List<String> uuids = List.of();
+ private int migrated = 0;
+
+ protected AbstractMigrateLiveMeasuresToMeasures(Database db, System2 system2, String tableName, String item) {
+ super(db);
+ this.system2 = system2;
+ this.tableName = tableName;
+ this.item = item;
+ }
+
+ private String getUpdateFlagQuery() {
+ return format("""
+ UPDATE %s
+ SET measures_migrated = ?
+ WHERE uuid = ?
+ """, tableName);
+ }
+
+ // This is a special entry point for the case of a configurable migration
+ public final void migrate(List<String> uuids) throws SQLException {
+ try (Connection readConnection = createDdlConnection();
+ Connection writeConnection = createDdlConnection()) {
+ Context context = new Context(db, readConnection, writeConnection);
+ this.uuids = uuids;
+ execute(context);
+ }
+ }
+
+ @Override
+ protected void execute(Context context) throws SQLException {
+ LOGGER.info("Starting the migration of {} {}s", uuids.size(), item);
+
+ for (String uuid : uuids) {
+ migrateItem(uuid, context);
+
+ migrated++;
+ if (migrated % 100 == 0) {
+ LOGGER.info("{} {}s migrated", migrated, item);
+ }
+ }
+ LOGGER.info("Finished migration of {} {}s", uuids.size(), item);
+ }
+
+ private void migrateItem(String uuid, Context context) throws SQLException {
+ LOGGER.debug("Migrating {} {}...", item, uuid);
+
+ Map<String, Object> measureValues = new HashMap<>();
+ AtomicReference<String> componentUuid = new AtomicReference<>(null);
+
+ MassUpdate massUpdate = context.prepareMassUpdate();
+ massUpdate.select(SELECT_QUERY).setString(1, uuid);
+ massUpdate.update(INSERT_QUERY);
+ massUpdate.execute((row, update) -> {
+ boolean shouldUpdate = false;
+ String rowComponentUuid = row.getString(1);
+ if (componentUuid.get() == null || !rowComponentUuid.equals(componentUuid.get())) {
+ if (!measureValues.isEmpty()) {
+ preparePersistMeasure(uuid, update, componentUuid, measureValues);
+ shouldUpdate = true;
+ }
+
+ LOGGER.debug("Starting processing of component {}...", rowComponentUuid);
+ componentUuid.set(rowComponentUuid);
+ measureValues.clear();
+ readMeasureValue(row, measureValues);
+ } else {
+ readMeasureValue(row, measureValues);
+ }
+ return shouldUpdate;
+ });
+ // insert the last component
+ if (!measureValues.isEmpty()) {
+ Upsert measureInsert = context.prepareUpsert(INSERT_QUERY);
+ preparePersistMeasure(uuid, measureInsert, componentUuid, measureValues);
+ measureInsert
+ .execute()
+ .commit();
+ }
+
+ LOGGER.debug("Flagging migration done for {} {}...", item, uuid);
+
+ context.prepareUpsert(getUpdateFlagQuery())
+ .setBoolean(1, true)
+ .setString(2, uuid)
+ .execute()
+ .commit();
+
+ LOGGER.debug("Migration finished for {} {}", item, uuid);
+ }
+
+ private void preparePersistMeasure(String uuid, SqlStatement<?> update, AtomicReference<String> componentUuid, Map<String, Object> measureValues) throws SQLException {
+ LOGGER.debug("Persisting measures for component {}...", componentUuid.get());
+ String jsonValue = GSON.toJson(measureValues);
+
+ long jsonHash = MurmurHash3.hash128(jsonValue.getBytes(UTF_8))[0];
+
+ update.setString(1, componentUuid.get());
+ update.setString(2, uuid);
+ update.setString(3, jsonValue);
+ update.setLong(4, jsonHash);
+ update.setLong(5, system2.now());
+ update.setLong(6, system2.now());
+ }
+
+ private static void readMeasureValue(Select.Row row, Map<String, Object> measureValues) throws SQLException {
+ String metricName = row.getString(2);
+ String valueType = row.getString(3);
+ Double numericValue = row.getDouble(4);
+ String textValue = row.getString(5);
+ byte[] data = row.getBytes(6);
+
+ Object metricValue = getMetricValue(data, textValue, valueType, numericValue);
+ if (metricValue != null) {
+ measureValues.put(metricName, metricValue);
+ }
+ }
+
+ private static Object getMetricValue(@Nullable byte[] data, @Nullable String textValue, String valueType, Double numericValue) {
+ return TEXT_VALUE_TYPES.contains(valueType) ? getTextValue(data, textValue) : numericValue;
+ }
+
+ private static String getTextValue(@Nullable byte[] data, @Nullable String textValue) {
+ return data != null ? new String(data, UTF_8) : textValue;
+ }
+}
diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasures.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasures.java
new file mode 100644
index 00000000000..3138470dcb2
--- /dev/null
+++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasures.java
@@ -0,0 +1,30 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.adhoc;
+
+import org.sonar.api.utils.System2;
+import org.sonar.db.Database;
+
+public class MigrateBranchesLiveMeasuresToMeasures extends AbstractMigrateLiveMeasuresToMeasures {
+
+ public MigrateBranchesLiveMeasuresToMeasures(Database db, System2 system2) {
+ super(db, system2, "project_branches", "branch");
+ }
+}
diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasures.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasures.java
new file mode 100644
index 00000000000..7ce171755f2
--- /dev/null
+++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasures.java
@@ -0,0 +1,30 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.adhoc;
+
+import org.sonar.api.utils.System2;
+import org.sonar.db.Database;
+
+public class MigratePortfoliosLiveMeasuresToMeasures extends AbstractMigrateLiveMeasuresToMeasures {
+
+ protected MigratePortfoliosLiveMeasuresToMeasures(Database db, System2 system2) {
+ super(db, system2, "portfolios", "portfolio");
+ }
+}
diff --git a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/DataChange.java b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/DataChange.java
index 01380acffc0..216ba7a2cb0 100644
--- a/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/DataChange.java
+++ b/server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/DataChange.java
@@ -26,7 +26,7 @@ import org.sonar.db.dialect.Dialect;
public abstract class DataChange implements MigrationStep {
- private final Database db;
+ protected final Database db;
public DataChange(Database db) {
this.db = db;
@@ -56,7 +56,7 @@ public abstract class DataChange implements MigrationStep {
return connection;
}
- private Connection createDdlConnection() throws SQLException {
+ protected Connection createDdlConnection() throws SQLException {
Connection res = db.getDataSource().getConnection();
res.setAutoCommit(false);
return res;
diff --git a/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest.java b/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest.java
new file mode 100644
index 00000000000..b7a251c6ce6
--- /dev/null
+++ b/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest.java
@@ -0,0 +1,180 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.adhoc;
+
+import java.nio.charset.StandardCharsets;
+import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.utils.System2;
+import org.sonar.core.util.SequenceUuidFactory;
+import org.sonar.db.CoreDbTester;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.Mockito.mock;
+
+public class MigrateBranchesLiveMeasuresToMeasuresTest {
+
+ private static final String MEASURES_MIGRATED_COLUMN = "measures_migrated";
+ public static final String SELECT_MEASURE = """
+ select component_uuid as "COMPONENT_UUID",
+ branch_uuid as "BRANCH_UUID",
+ json_value as "JSON_VALUE",
+ json_value_hash as "JSON_VALUE_HASH",
+ created_at as "CREATED_AT",
+ updated_at as "UPDATED_AT",
+ from measures
+ where component_uuid = '%s'""";
+
+ @Rule
+ public final CoreDbTester db = CoreDbTester.createForSchema(MigrateBranchesLiveMeasuresToMeasuresTest.class, "schema.sql");
+
+ private final SequenceUuidFactory uuidFactory = new SequenceUuidFactory();
+ private final System2 system2 = mock();
+ private final MigrateBranchesLiveMeasuresToMeasures underTest = new MigrateBranchesLiveMeasuresToMeasures(db.database(), system2);
+
+ @Test
+ public void shall_do_nothing_when_called_by_execute() {
+ assertThatCode(underTest::execute)
+ .doesNotThrowAnyException();
+
+ assertThat(db.countRowsOfTable("measures")).isZero();
+ }
+
+ @Test
+ public void shall_complete_when_tables_are_empty() throws SQLException {
+ underTest.migrate(List.of("unused"));
+
+ assertThat(db.countRowsOfTable("measures")).isZero();
+ }
+
+ @Test
+ public void should_flag_branch_with_no_measures() throws SQLException {
+ String branch = "branch_3";
+ insertNotMigratedBranch(branch);
+
+ underTest.migrate(List.of(branch));
+
+ assertBranchMigrated(branch);
+ assertThat(db.countRowsOfTable("measures")).isZero();
+ }
+
+ @Test
+ public void should_migrate_branch_with_measures() throws SQLException {
+ String nclocMetricUuid = insertMetric("ncloc", "INT");
+ String qgStatusMetricUuid = insertMetric("quality_gate_status", "STRING");
+ String metricWithDataUuid = insertMetric("metric_with_data", "DATA");
+
+ String branch1 = "branch_4";
+ insertNotMigratedBranch(branch1);
+ String component1 = uuidFactory.create();
+ String component2 = uuidFactory.create();
+ insertMeasure(branch1, component1, nclocMetricUuid, Map.of("value", 120));
+ insertMeasure(branch1, component1, qgStatusMetricUuid, Map.of("text_value", "ok"));
+ insertMeasure(branch1, component2, metricWithDataUuid, Map.of("measure_data", "some data".getBytes(StandardCharsets.UTF_8)));
+
+ String branch2 = "branch_5";
+ insertNotMigratedBranch(branch2);
+ insertMeasure(branch2, nclocMetricUuid, Map.of("value", 64));
+
+ String unusedBranch = "branch_6";
+ insertNotMigratedBranch(unusedBranch);
+ insertMeasure(unusedBranch, nclocMetricUuid, Map.of("value", 3684));
+
+ underTest.migrate(List.of(branch1, branch2));
+
+ assertBranchMigrated(branch1);
+ assertBranchMigrated(branch2);
+ assertBranchNotMigrated(unusedBranch);
+ assertThat(db.countRowsOfTable("measures")).isEqualTo(3);
+
+ assertThat(db.select(format(SELECT_MEASURE, component1)))
+ .hasSize(1)
+ .extracting(t -> t.get("COMPONENT_UUID"), t -> t.get("BRANCH_UUID"), t -> t.get("JSON_VALUE"), t -> t.get("JSON_VALUE_HASH"))
+ .containsOnly(tuple(component1, branch1, "{\"ncloc\":120.0,\"quality_gate_status\":\"ok\"}", 6033012287291512746L));
+
+ assertThat(db.select(format(SELECT_MEASURE, component2)))
+ .hasSize(1)
+ .extracting(t -> t.get("COMPONENT_UUID"), t -> t.get("BRANCH_UUID"), t -> t.get("JSON_VALUE"), t -> t.get("JSON_VALUE_HASH"))
+ .containsOnly(tuple(component2, branch1, "{\"metric_with_data\":\"some data\"}", -4524184678167636687L));
+ }
+
+ private void assertBranchMigrated(String branch) {
+ assertMigrationStatus(branch, true);
+ }
+
+ private void assertBranchNotMigrated(String branch) {
+ assertMigrationStatus(branch, false);
+ }
+
+ private void assertMigrationStatus(String branch, boolean expected) {
+ List<Map<String, Object>> result = db.select(format("select %s as \"MIGRATED\" from project_branches where uuid = '%s'", MEASURES_MIGRATED_COLUMN, branch));
+ assertThat(result)
+ .hasSize(1)
+ .extracting(t -> t.get("MIGRATED"))
+ .containsOnly(expected);
+ }
+
+ private String insertMetric(String metricName, String valueType) {
+ String metricUuid = uuidFactory.create();
+ db.executeInsert("metrics",
+ "uuid", metricUuid,
+ "name", metricName,
+ "val_type", valueType);
+ return metricUuid;
+ }
+
+ private void insertMeasure(String branchUuid, String metricUuid, Map<String, Object> data) {
+ insertMeasure(branchUuid, uuidFactory.create(), metricUuid, data);
+ }
+
+ private void insertMeasure(String branchUuid, String componentUuid, String metricUuid, Map<String, Object> data) {
+ Map<String, Object> dataMap = new HashMap<>(data);
+ dataMap.put("uuid", uuidFactory.create());
+ dataMap.put("component_uuid", componentUuid);
+ dataMap.put("project_uuid", branchUuid);
+ dataMap.put("metric_uuid", metricUuid);
+ dataMap.put("created_at", 12L);
+ dataMap.put("updated_at", 12L);
+
+ db.executeInsert("live_measures", dataMap);
+ }
+
+ private void insertNotMigratedBranch(String branchUuid) {
+ db.executeInsert("project_branches",
+ "uuid", branchUuid,
+ "kee", branchUuid,
+ "branch_type", "LONG",
+ "project_uuid", uuidFactory.create(),
+ MEASURES_MIGRATED_COLUMN, false,
+ "need_issue_sync", false,
+ "created_at", 12L,
+ "updated_at", 12L
+ );
+ }
+
+
+}
diff --git a/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest.java b/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest.java
new file mode 100644
index 00000000000..dd76bd3d735
--- /dev/null
+++ b/server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest.java
@@ -0,0 +1,178 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.platform.db.migration.adhoc;
+
+import java.nio.charset.StandardCharsets;
+import java.sql.SQLException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.junit.Rule;
+import org.junit.Test;
+import org.sonar.api.utils.System2;
+import org.sonar.core.util.SequenceUuidFactory;
+import org.sonar.db.CoreDbTester;
+
+import static java.lang.String.format;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.mockito.Mockito.mock;
+
+public class MigratePortfoliosLiveMeasuresToMeasuresTest {
+
+ private static final String MEASURES_MIGRATED_COLUMN = "measures_migrated";
+ public static final String SELECT_MEASURE = """
+ select component_uuid as "COMPONENT_UUID",
+ branch_uuid as "BRANCH_UUID",
+ json_value as "JSON_VALUE",
+ json_value_hash as "JSON_VALUE_HASH",
+ created_at as "CREATED_AT",
+ updated_at as "UPDATED_AT",
+ from measures where component_uuid = '%s'""";
+
+ @Rule
+ public final CoreDbTester db = CoreDbTester.createForSchema(MigratePortfoliosLiveMeasuresToMeasuresTest.class, "schema.sql");
+
+ private final SequenceUuidFactory uuidFactory = new SequenceUuidFactory();
+ private final System2 system2 = mock();
+ private final MigratePortfoliosLiveMeasuresToMeasures underTest = new MigratePortfoliosLiveMeasuresToMeasures(db.database(), system2);
+
+ @Test
+ public void shall_do_nothing_when_called_by_execute() {
+ assertThatCode(underTest::execute)
+ .doesNotThrowAnyException();
+
+ assertThat(db.countRowsOfTable("measures")).isZero();
+ }
+
+ @Test
+ public void shall_complete_when_tables_are_empty() throws SQLException {
+ underTest.migrate(List.of("unused"));
+
+ assertThat(db.countRowsOfTable("measures")).isZero();
+ }
+
+ @Test
+ public void should_flag_portfolio_with_no_measures() throws SQLException {
+ String portfolio = "portfolio_3";
+ insertNotMigratedPortfolio(portfolio);
+
+ underTest.migrate(List.of(portfolio));
+
+ assertPortfolioMigrated(portfolio);
+ assertThat(db.countRowsOfTable("measures")).isZero();
+ }
+
+ @Test
+ public void should_migrate_portfolio_with_measures() throws SQLException {
+ String nclocMetricUuid = insertMetric("ncloc", "INT");
+ String qgStatusMetricUuid = insertMetric("quality_gate_status", "STRING");
+ String metricWithDataUuid = insertMetric("metric_with_data", "DATA");
+
+ String portfolio1 = "portfolio_4";
+ insertNotMigratedPortfolio(portfolio1);
+ String component1 = uuidFactory.create();
+ String component2 = uuidFactory.create();
+ insertMeasure(portfolio1, component1, nclocMetricUuid, Map.of("value", 120));
+ insertMeasure(portfolio1, component1, qgStatusMetricUuid, Map.of("text_value", "ok"));
+ insertMeasure(portfolio1, component2, metricWithDataUuid, Map.of("measure_data", "some data".getBytes(StandardCharsets.UTF_8)));
+
+ String portfolio2 = "portfolio_5";
+ insertNotMigratedPortfolio(portfolio2);
+ insertMeasure(portfolio2, nclocMetricUuid, Map.of("value", 64));
+
+ String unusedPortfolio = "portfolio_6";
+ insertNotMigratedPortfolio(unusedPortfolio);
+ insertMeasure(unusedPortfolio, nclocMetricUuid, Map.of("value", 3684));
+
+ underTest.migrate(List.of(portfolio1, portfolio2));
+
+ assertPortfolioMigrated(portfolio1);
+ assertPortfolioMigrated(portfolio2);
+ assertPortfolioNotMigrated(unusedPortfolio);
+ assertThat(db.countRowsOfTable("measures")).isEqualTo(3);
+
+ assertThat(db.select(format(SELECT_MEASURE, component1)))
+ .hasSize(1)
+ .extracting(t -> t.get("COMPONENT_UUID"), t -> t.get("BRANCH_UUID"), t -> t.get("JSON_VALUE"), t -> t.get("JSON_VALUE_HASH"))
+ .containsOnly(tuple(component1, portfolio1, "{\"ncloc\":120.0,\"quality_gate_status\":\"ok\"}", 6033012287291512746L));
+
+ assertThat(db.select(format(SELECT_MEASURE, component2)))
+ .hasSize(1)
+ .extracting(t -> t.get("COMPONENT_UUID"), t -> t.get("BRANCH_UUID"), t -> t.get("JSON_VALUE"), t -> t.get("JSON_VALUE_HASH"))
+ .containsOnly(tuple(component2, portfolio1, "{\"metric_with_data\":\"some data\"}", -4524184678167636687L));
+ }
+
+ private void assertPortfolioMigrated(String portfolio) {
+ assertMigrationStatus(portfolio, true);
+ }
+
+ private void assertPortfolioNotMigrated(String portfolio) {
+ assertMigrationStatus(portfolio, false);
+ }
+
+ private void assertMigrationStatus(String portfolio, boolean expected) {
+ List<Map<String, Object>> result = db.select(format("select %s as \"MIGRATED\" from portfolios where uuid = '%s'", MEASURES_MIGRATED_COLUMN, portfolio));
+ assertThat(result)
+ .hasSize(1)
+ .extracting(t -> t.get("MIGRATED"))
+ .containsOnly(expected);
+ }
+
+ private String insertMetric(String metricName, String valueType) {
+ String metricUuid = uuidFactory.create();
+ db.executeInsert("metrics",
+ "uuid", metricUuid,
+ "name", metricName,
+ "val_type", valueType);
+ return metricUuid;
+ }
+
+ private void insertMeasure(String portfolioUuid, String metricUuid, Map<String, Object> data) {
+ insertMeasure(portfolioUuid, uuidFactory.create(), metricUuid, data);
+ }
+
+ private void insertMeasure(String portfolioUuid, String componentUuid, String metricUuid, Map<String, Object> data) {
+ Map<String, Object> dataMap = new HashMap<>(data);
+ dataMap.put("uuid", uuidFactory.create());
+ dataMap.put("component_uuid", componentUuid);
+ dataMap.put("project_uuid", portfolioUuid);
+ dataMap.put("metric_uuid", metricUuid);
+ dataMap.put("created_at", 12L);
+ dataMap.put("updated_at", 12L);
+
+ db.executeInsert("live_measures", dataMap);
+ }
+
+ private void insertNotMigratedPortfolio(String portfolioUuid) {
+ db.executeInsert("portfolios",
+ "uuid", portfolioUuid,
+ "kee", portfolioUuid,
+ "name", portfolioUuid,
+ "private", true,
+ "root_uuid", portfolioUuid,
+ "selection_mode", "MANUAL",
+ MEASURES_MIGRATED_COLUMN, false,
+ "created_at", 12L,
+ "updated_at", 12L
+ );
+ }
+}
diff --git a/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest/schema.sql b/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest/schema.sql
new file mode 100644
index 00000000000..4d3f9e76ad5
--- /dev/null
+++ b/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest/schema.sql
@@ -0,0 +1,63 @@
+CREATE TABLE "PROJECT_BRANCHES"(
+ "UUID" CHARACTER VARYING(50) NOT NULL,
+ "PROJECT_UUID" CHARACTER VARYING(50) NOT NULL,
+ "KEE" CHARACTER VARYING(255) NOT NULL,
+ "BRANCH_TYPE" CHARACTER VARYING(12) NOT NULL,
+ "MERGE_BRANCH_UUID" CHARACTER VARYING(50),
+ "PULL_REQUEST_BINARY" BINARY LARGE OBJECT,
+ "MANUAL_BASELINE_ANALYSIS_UUID" CHARACTER VARYING(40),
+ "CREATED_AT" BIGINT NOT NULL,
+ "UPDATED_AT" BIGINT NOT NULL,
+ "EXCLUDE_FROM_PURGE" BOOLEAN DEFAULT FALSE NOT NULL,
+ "NEED_ISSUE_SYNC" BOOLEAN NOT NULL,
+ "MEASURES_MIGRATED" BOOLEAN DEFAULT FALSE NOT NULL
+);
+ALTER TABLE "PROJECT_BRANCHES" ADD CONSTRAINT "PK_PROJECT_BRANCHES" PRIMARY KEY("UUID");
+CREATE UNIQUE INDEX "UNIQ_PROJECT_BRANCHES" ON "PROJECT_BRANCHES"("BRANCH_TYPE" NULLS FIRST, "PROJECT_UUID" NULLS FIRST, "KEE" NULLS FIRST);
+
+CREATE TABLE "METRICS"(
+ "UUID" CHARACTER VARYING(40) NOT NULL,
+ "NAME" CHARACTER VARYING(64) NOT NULL,
+ "DESCRIPTION" CHARACTER VARYING(255),
+ "DIRECTION" INTEGER DEFAULT 0 NOT NULL,
+ "DOMAIN" CHARACTER VARYING(64),
+ "SHORT_NAME" CHARACTER VARYING(64),
+ "QUALITATIVE" BOOLEAN DEFAULT FALSE NOT NULL,
+ "VAL_TYPE" CHARACTER VARYING(8),
+ "ENABLED" BOOLEAN DEFAULT TRUE,
+ "WORST_VALUE" DOUBLE PRECISION,
+ "BEST_VALUE" DOUBLE PRECISION,
+ "OPTIMIZED_BEST_VALUE" BOOLEAN,
+ "HIDDEN" BOOLEAN,
+ "DELETE_HISTORICAL_DATA" BOOLEAN,
+ "DECIMAL_SCALE" INTEGER
+);
+ALTER TABLE "METRICS" ADD CONSTRAINT "PK_METRICS" PRIMARY KEY("UUID");
+CREATE UNIQUE INDEX "METRICS_UNIQUE_NAME" ON "METRICS"("NAME" NULLS FIRST);
+
+CREATE TABLE "LIVE_MEASURES"(
+ "UUID" CHARACTER VARYING(40) NOT NULL,
+ "PROJECT_UUID" CHARACTER VARYING(50) NOT NULL,
+ "COMPONENT_UUID" CHARACTER VARYING(50) NOT NULL,
+ "METRIC_UUID" CHARACTER VARYING(40) NOT NULL,
+ "VALUE" DOUBLE PRECISION,
+ "TEXT_VALUE" CHARACTER VARYING(4000),
+ "MEASURE_DATA" BINARY LARGE OBJECT,
+ "UPDATE_MARKER" CHARACTER VARYING(40),
+ "CREATED_AT" BIGINT NOT NULL,
+ "UPDATED_AT" BIGINT NOT NULL
+);
+ALTER TABLE "LIVE_MEASURES" ADD CONSTRAINT "PK_LIVE_MEASURES" PRIMARY KEY("UUID");
+CREATE INDEX "LIVE_MEASURES_PROJECT" ON "LIVE_MEASURES"("PROJECT_UUID" NULLS FIRST);
+CREATE UNIQUE INDEX "LIVE_MEASURES_COMPONENT" ON "LIVE_MEASURES"("COMPONENT_UUID" NULLS FIRST, "METRIC_UUID" NULLS FIRST);
+
+CREATE TABLE "MEASURES"(
+ "COMPONENT_UUID" CHARACTER VARYING(40) NOT NULL,
+ "BRANCH_UUID" CHARACTER VARYING(40) NOT NULL,
+ "JSON_VALUE" CHARACTER LARGE OBJECT NOT NULL,
+ "JSON_VALUE_HASH" BIGINT NOT NULL,
+ "CREATED_AT" BIGINT NOT NULL,
+ "UPDATED_AT" BIGINT NOT NULL
+);
+ALTER TABLE "MEASURES" ADD CONSTRAINT "PK_MEASURES" PRIMARY KEY("COMPONENT_UUID");
+CREATE INDEX "MEASURES_BRANCH_UUID" ON "MEASURES"("BRANCH_UUID" NULLS FIRST);
diff --git a/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest/schema.sql b/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest/schema.sql
new file mode 100644
index 00000000000..2b4deb7f118
--- /dev/null
+++ b/server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest/schema.sql
@@ -0,0 +1,64 @@
+CREATE TABLE "PORTFOLIOS"(
+ "UUID" CHARACTER VARYING(40) NOT NULL,
+ "KEE" CHARACTER VARYING(400) NOT NULL,
+ "NAME" CHARACTER VARYING(2000) NOT NULL,
+ "DESCRIPTION" CHARACTER VARYING(2000),
+ "ROOT_UUID" CHARACTER VARYING(40) NOT NULL,
+ "PARENT_UUID" CHARACTER VARYING(40),
+ "PRIVATE" BOOLEAN NOT NULL,
+ "SELECTION_MODE" CHARACTER VARYING(50) NOT NULL,
+ "SELECTION_EXPRESSION" CHARACTER VARYING(4000),
+ "CREATED_AT" BIGINT NOT NULL,
+ "UPDATED_AT" BIGINT NOT NULL,
+ "BRANCH_KEY" CHARACTER VARYING(255),
+ "MEASURES_MIGRATED" BOOLEAN DEFAULT FALSE NOT NULL
+);
+ALTER TABLE "PORTFOLIOS" ADD CONSTRAINT "PK_PORTFOLIOS" PRIMARY KEY("UUID");
+CREATE UNIQUE INDEX "UNIQ_PORTFOLIOS_KEE" ON "PORTFOLIOS"("KEE" NULLS FIRST);
+
+CREATE TABLE "METRICS"(
+ "UUID" CHARACTER VARYING(40) NOT NULL,
+ "NAME" CHARACTER VARYING(64) NOT NULL,
+ "DESCRIPTION" CHARACTER VARYING(255),
+ "DIRECTION" INTEGER DEFAULT 0 NOT NULL,
+ "DOMAIN" CHARACTER VARYING(64),
+ "SHORT_NAME" CHARACTER VARYING(64),
+ "QUALITATIVE" BOOLEAN DEFAULT FALSE NOT NULL,
+ "VAL_TYPE" CHARACTER VARYING(8),
+ "ENABLED" BOOLEAN DEFAULT TRUE,
+ "WORST_VALUE" DOUBLE PRECISION,
+ "BEST_VALUE" DOUBLE PRECISION,
+ "OPTIMIZED_BEST_VALUE" BOOLEAN,
+ "HIDDEN" BOOLEAN,
+ "DELETE_HISTORICAL_DATA" BOOLEAN,
+ "DECIMAL_SCALE" INTEGER
+);
+ALTER TABLE "METRICS" ADD CONSTRAINT "PK_METRICS" PRIMARY KEY("UUID");
+CREATE UNIQUE INDEX "METRICS_UNIQUE_NAME" ON "METRICS"("NAME" NULLS FIRST);
+
+CREATE TABLE "LIVE_MEASURES"(
+ "UUID" CHARACTER VARYING(40) NOT NULL,
+ "PROJECT_UUID" CHARACTER VARYING(50) NOT NULL,
+ "COMPONENT_UUID" CHARACTER VARYING(50) NOT NULL,
+ "METRIC_UUID" CHARACTER VARYING(40) NOT NULL,
+ "VALUE" DOUBLE PRECISION,
+ "TEXT_VALUE" CHARACTER VARYING(4000),
+ "MEASURE_DATA" BINARY LARGE OBJECT,
+ "UPDATE_MARKER" CHARACTER VARYING(40),
+ "CREATED_AT" BIGINT NOT NULL,
+ "UPDATED_AT" BIGINT NOT NULL
+);
+ALTER TABLE "LIVE_MEASURES" ADD CONSTRAINT "PK_LIVE_MEASURES" PRIMARY KEY("UUID");
+CREATE INDEX "LIVE_MEASURES_PROJECT" ON "LIVE_MEASURES"("PROJECT_UUID" NULLS FIRST);
+CREATE UNIQUE INDEX "LIVE_MEASURES_COMPONENT" ON "LIVE_MEASURES"("COMPONENT_UUID" NULLS FIRST, "METRIC_UUID" NULLS FIRST);
+
+CREATE TABLE "MEASURES"(
+ "COMPONENT_UUID" CHARACTER VARYING(40) NOT NULL,
+ "BRANCH_UUID" CHARACTER VARYING(40) NOT NULL,
+ "JSON_VALUE" CHARACTER LARGE OBJECT NOT NULL,
+ "JSON_VALUE_HASH" BIGINT NOT NULL,
+ "CREATED_AT" BIGINT NOT NULL,
+ "UPDATED_AT" BIGINT NOT NULL
+);
+ALTER TABLE "MEASURES" ADD CONSTRAINT "PK_MEASURES" PRIMARY KEY("COMPONENT_UUID");
+CREATE INDEX "MEASURES_BRANCH_UUID" ON "MEASURES"("BRANCH_UUID" NULLS FIRST);
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/MigrateMeasuresAction.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/MigrateMeasuresAction.java
new file mode 100644
index 00000000000..fd7d6b35357
--- /dev/null
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/MigrateMeasuresAction.java
@@ -0,0 +1,153 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.platform.ws;
+
+import com.google.common.io.Resources;
+import java.sql.SQLException;
+import java.util.List;
+import org.sonar.api.server.ws.Request;
+import org.sonar.api.server.ws.Response;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.api.utils.text.JsonWriter;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.server.platform.db.migration.adhoc.MigrateBranchesLiveMeasuresToMeasures;
+import org.sonar.server.platform.db.migration.adhoc.MigratePortfoliosLiveMeasuresToMeasures;
+import org.sonar.server.user.UserSession;
+
+import static java.lang.String.format;
+import static java.util.Optional.ofNullable;
+
+public class MigrateMeasuresAction implements SystemWsAction {
+ public static final String SYSTEM_MEASURES_MIGRATION_ENABLED = "system.measures.migration.enabled";
+ public static final String PARAM_SIZE = "size";
+
+ private final UserSession userSession;
+ private final DbClient dbClient;
+ private final MigrateBranchesLiveMeasuresToMeasures branchesMigration;
+ private final MigratePortfoliosLiveMeasuresToMeasures portfoliosMigration;
+
+ public MigrateMeasuresAction(UserSession userSession, DbClient dbClient,
+ MigrateBranchesLiveMeasuresToMeasures branchesMigration, MigratePortfoliosLiveMeasuresToMeasures portfoliosMigration) {
+ this.userSession = userSession;
+ this.dbClient = dbClient;
+ this.branchesMigration = branchesMigration;
+ this.portfoliosMigration = portfoliosMigration;
+ }
+
+ @Override
+ public void define(WebService.NewController controller) {
+ WebService.NewAction action = controller.createAction("migrate_measures")
+ .setDescription("Prepare the migration to the next major version of SonarQube." +
+ "<br/>" +
+ "Sending a POST request to this URL will migrate some rows from the 'live_measures' to the 'measures' table. " +
+ "Requires system administration permission.")
+ .setSince("9.9.8")
+ .setPost(true)
+ .setHandler(this)
+ .setInternal(true)
+ .setResponseExample(Resources.getResource(this.getClass(), "example-migrate_measures.json"));
+
+ action.createParam(PARAM_SIZE)
+ .setDescription("The number of branches or portfolios to migrate")
+ .setDefaultValue(10);
+ }
+
+ @Override
+ public void handle(Request request, Response response) throws Exception {
+ userSession.checkIsSystemAdministrator();
+
+ if (!isMigrationEnabled()) {
+ throw new IllegalStateException("Migration is not enabled. Please call the endpoint /api/system/prepare_migration?enable=true and retry.");
+ }
+
+ int size = request.mandatoryParamAsInt(PARAM_SIZE);
+ if (size <= 0) {
+ throw new IllegalArgumentException("Size must be greater than 0");
+ }
+
+ int migratedItems = migrateBranches(size);
+ if (migratedItems < size) {
+ int remainingSize = size - migratedItems;
+ migratedItems += migratePortfolios(remainingSize);
+ }
+
+ BranchStats statistics = getStatistics();
+ try (JsonWriter json = response.newJsonWriter()) {
+ json.beginObject()
+ .prop("status", "success")
+ .prop("message", format("%s branches or portfolios migrated", migratedItems))
+ .prop("remainingBranches", statistics.remainingBranches)
+ .prop("totalBranches", statistics.totalBranches)
+ .prop("remainingPortfolios", statistics.remainingPortfolios)
+ .prop("totalPortfolios", statistics.totalPortfolios)
+ .endObject();
+ }
+ }
+
+ private int migrateBranches(int size) throws SQLException {
+ List<String> branchesToMigrate = getBranchesToMigrate(size);
+ if (!branchesToMigrate.isEmpty()) {
+ branchesMigration.migrate(branchesToMigrate);
+ }
+ return branchesToMigrate.size();
+ }
+
+ private List<String> getBranchesToMigrate(int size) {
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ return dbClient.branchDao().selectUuidsWithMeasuresMigratedFalse(dbSession, size);
+ }
+ }
+
+ private int migratePortfolios(int size) throws SQLException {
+ List<String> portfoliosToMigrate = getPortfoliosToMigrate(size);
+ if (!portfoliosToMigrate.isEmpty()) {
+ portfoliosMigration.migrate(portfoliosToMigrate);
+ }
+ return portfoliosToMigrate.size();
+ }
+
+ private List<String> getPortfoliosToMigrate(int size) {
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ return dbClient.portfolioDao().selectUuidsWithMeasuresMigratedFalse(dbSession, size);
+ }
+ }
+
+ private boolean isMigrationEnabled() {
+ return ofNullable(dbClient.propertiesDao().selectGlobalProperty(SYSTEM_MEASURES_MIGRATION_ENABLED))
+ .map(p -> Boolean.parseBoolean(p.getValue()))
+ .orElse(false);
+ }
+
+ private BranchStats getStatistics() {
+ try (DbSession dbSession = dbClient.openSession(false)) {
+ int remainingBranches = dbClient.branchDao().countByMeasuresMigratedFalse(dbSession);
+ int totalBranches = dbClient.branchDao().countAll(dbSession);
+ int remainingPortfolios = dbClient.portfolioDao().countByMeasuresMigratedFalse(dbSession);
+ int totalPortfolios = dbClient.portfolioDao().selectAll(dbSession).size();
+
+ return new BranchStats(remainingBranches, totalBranches, remainingPortfolios, totalPortfolios);
+ }
+ }
+
+ private record BranchStats(int remainingBranches, int totalBranches, int remainingPortfolios, int totalPortfolios) {
+ }
+
+}
diff --git a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/SystemWsModule.java b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/SystemWsModule.java
index 5807d697a88..41c21b03174 100644
--- a/server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/SystemWsModule.java
+++ b/server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/SystemWsModule.java
@@ -25,6 +25,8 @@ import org.sonar.server.platform.db.migration.adhoc.AddMeasuresMigratedColumnToP
import org.sonar.server.platform.db.migration.adhoc.CreateIndexOnPortfoliosMeasuresMigrated;
import org.sonar.server.platform.db.migration.adhoc.CreateIndexOnProjectBranchesMeasuresMigrated;
import org.sonar.server.platform.db.migration.adhoc.CreateMeasuresTable;
+import org.sonar.server.platform.db.migration.adhoc.MigrateBranchesLiveMeasuresToMeasures;
+import org.sonar.server.platform.db.migration.adhoc.MigratePortfoliosLiveMeasuresToMeasures;
public class SystemWsModule extends Module {
@@ -47,6 +49,10 @@ public class SystemWsModule extends Module {
CreateIndexOnPortfoliosMeasuresMigrated.class,
PrepareMigrationAction.class,
+ MigrateBranchesLiveMeasuresToMeasures.class,
+ MigratePortfoliosLiveMeasuresToMeasures.class,
+ MigrateMeasuresAction.class,
+
InfoAction.class,
LogsAction.class,
MigrateDbAction.class,
diff --git a/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/platform/ws/example-migrate_measures.json b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/platform/ws/example-migrate_measures.json
new file mode 100644
index 00000000000..60fb3154d7a
--- /dev/null
+++ b/server/sonar-webserver-webapi/src/main/resources/org/sonar/server/platform/ws/example-migrate_measures.json
@@ -0,0 +1,8 @@
+{
+ "status": "success",
+ "message": "2 branches or portfolios migrated",
+ "remainingBranches": 1,
+ "totalBranches": 3,
+ "remainingPortfolios": 1,
+ "totalPortfolios": 2
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/MigrateMeasuresActionTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/MigrateMeasuresActionTest.java
new file mode 100644
index 00000000000..a4b92076fca
--- /dev/null
+++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/MigrateMeasuresActionTest.java
@@ -0,0 +1,233 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+package org.sonar.server.platform.ws;
+
+import com.google.gson.Gson;
+import com.tngtech.java.junit.dataprovider.DataProvider;
+import com.tngtech.java.junit.dataprovider.DataProviderRunner;
+import java.sql.SQLException;
+import java.util.List;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.sonar.db.Database;
+import org.sonar.db.DbTester;
+import org.sonar.db.component.BranchType;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.portfolio.PortfolioDto;
+import org.sonar.db.property.PropertyDto;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.platform.db.migration.adhoc.AddMeasuresMigratedColumnToPortfoliosTable;
+import org.sonar.server.platform.db.migration.adhoc.AddMeasuresMigratedColumnToProjectBranchesTable;
+import org.sonar.server.platform.db.migration.adhoc.MigrateBranchesLiveMeasuresToMeasures;
+import org.sonar.server.platform.db.migration.adhoc.MigratePortfoliosLiveMeasuresToMeasures;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
+import org.sonar.server.ws.TestResponse;
+import org.sonar.server.ws.WsActionTester;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.sonar.db.component.BranchType.BRANCH;
+import static org.sonar.server.platform.ws.MigrateMeasuresAction.SYSTEM_MEASURES_MIGRATION_ENABLED;
+import static org.sonar.test.JsonAssert.assertJson;
+
+@RunWith(DataProviderRunner.class)
+public class MigrateMeasuresActionTest {
+ private static final Gson GSON = new Gson();
+
+ public static final String PARAM_SIZE = "size";
+ @Rule
+ public UserSessionRule userSessionRule = UserSessionRule.standalone().logIn().setSystemAdministrator();
+
+ @Rule
+ public DbTester dbTester = DbTester.create();
+
+ private final MigrateBranchesLiveMeasuresToMeasures measuresMigration = mock();
+ private final MigratePortfoliosLiveMeasuresToMeasures portfoliosMigration = mock();
+ private final MigrateMeasuresAction underTest = new MigrateMeasuresAction(userSessionRule, dbTester.getDbClient(), measuresMigration, portfoliosMigration);
+ private final WsActionTester tester = new WsActionTester(underTest);
+
+ @Test
+ public void should_throw_if_migration_is_not_enabled() {
+ TestRequest request = tester.newRequest();
+
+ assertThatIllegalStateException()
+ .isThrownBy(request::execute)
+ .withMessage("Migration is not enabled. Please call the endpoint /api/system/prepare_migration?enable=true and retry.");
+ }
+
+ @Test
+ @DataProvider(value = {"0", "-1", "-100"})
+ public void should_throws_IAE_if_size_in_invalid(int size) throws SQLException {
+ enableMigration();
+
+ TestRequest request = tester
+ .newRequest()
+ .setParam(PARAM_SIZE, Integer.toString(size));
+
+ assertThatIllegalArgumentException()
+ .isThrownBy(request::execute)
+ .withMessage("Size must be greater than 0");
+ }
+
+ @Test
+ public void verify_example() throws SQLException {
+ enableMigration();
+ // 3 branches, 2 migrated
+ ComponentDto project = dbTester.components().insertPrivateProject();
+ ComponentDto branch1 = dbTester.components().insertProjectBranch(project, b -> b.setBranchType(BRANCH));
+ dbTester.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.BRANCH));
+ dbTester.getDbClient().branchDao().updateMeasuresMigrated(dbTester.getSession(), project.branchUuid(), true);
+ dbTester.getDbClient().branchDao().updateMeasuresMigrated(dbTester.getSession(), branch1.branchUuid(), true);
+ // 2 portfolios, 1 migrated
+ PortfolioDto portfolio1 = dbTester.components().insertPrivatePortfolioDto("name1");
+ dbTester.components().insertPrivatePortfolioDto("name2");
+ dbTester.getDbClient().portfolioDao().updateMeasuresMigrated(dbTester.getSession(), portfolio1.getUuid(), true);
+ dbTester.getSession().commit();
+
+ TestResponse response = tester.newRequest()
+ .execute();
+
+ assertJson(response.getInput()).isSimilarTo(getClass().getResource("example-migrate_measures.json"));
+ }
+
+ @Test
+ public void does_not_migrate_portfolios_if_measures_are_not_finished() throws SQLException {
+ enableMigration();
+ // 2 branches
+ ComponentDto project = dbTester.components().insertPrivateProject();
+ ComponentDto branch = dbTester.components().insertProjectBranch(project, b -> b.setBranchType(BRANCH));
+ dbTester.components().insertProjectBranch(project, b -> b.setBranchType(BranchType.BRANCH));
+
+ TestResponse response = tester.newRequest()
+ .setParam(PARAM_SIZE, "2")
+ .execute();
+
+ assertThat(GSON.fromJson(response.getInput(), ActionResponse.class))
+ .isEqualTo(new ActionResponse("success", "2 branches or portfolios migrated", 3, 3, 0, 0));
+ verify(measuresMigration).migrate(List.of(project.uuid(), branch.uuid()));
+ verifyNoInteractions(portfoliosMigration);
+ }
+
+ @Test
+ public void migrate_portfolios_to_reach_the_requested_size() throws SQLException {
+ enableMigration();
+
+ // 1 branch
+ ComponentDto project = dbTester.components().insertPrivateProject();
+ // 2 portfolios
+ PortfolioDto portfolio1 = dbTester.components().insertPrivatePortfolioDto("name1");
+ dbTester.components().insertPrivatePortfolioDto("name2");
+
+ TestResponse response = tester.newRequest()
+ .setParam(PARAM_SIZE, "2")
+ .execute();
+
+ assertThat(GSON.fromJson(response.getInput(), ActionResponse.class))
+ .isEqualTo(new ActionResponse("success", "2 branches or portfolios migrated", 1, 1, 2, 2));
+ verify(measuresMigration).migrate(List.of(project.uuid()));
+ verify(portfoliosMigration).migrate(List.of(portfolio1.getUuid()));
+ }
+
+ @Test
+ public void migrate_portfolios_only_if_measures_are_done() throws SQLException {
+ enableMigration();
+ // 2 branches, all migrated
+ ComponentDto project = dbTester.components().insertPrivateProject();
+ ComponentDto branch1 = dbTester.components().insertProjectBranch(project, b -> b.setBranchType(BRANCH));
+ dbTester.getDbClient().branchDao().updateMeasuresMigrated(dbTester.getSession(), project.branchUuid(), true);
+ dbTester.getDbClient().branchDao().updateMeasuresMigrated(dbTester.getSession(), branch1.branchUuid(), true);
+ // 2 portfolios, 1 migrated
+ PortfolioDto portfolio1 = dbTester.components().insertPrivatePortfolioDto("name1");
+ PortfolioDto portfolio2 = dbTester.components().insertPrivatePortfolioDto("name2");
+ dbTester.getDbClient().portfolioDao().updateMeasuresMigrated(dbTester.getSession(), portfolio1.getUuid(), true);
+ dbTester.commit();
+
+ TestResponse response = tester.newRequest()
+ .setParam(PARAM_SIZE, "2")
+ .execute();
+
+ assertThat(GSON.fromJson(response.getInput(), ActionResponse.class))
+ .isEqualTo(new ActionResponse("success", "1 branches or portfolios migrated", 0, 2, 1, 2));
+ verifyNoInteractions(measuresMigration);
+ verify(portfoliosMigration).migrate(List.of(portfolio2.getUuid()));
+ }
+
+ @Test
+ public void does_nothing_if_migration_is_finished() throws SQLException {
+ enableMigration();
+ // 2 branches, all migrated
+ ComponentDto project = dbTester.components().insertPrivateProject();
+ ComponentDto branch1 = dbTester.components().insertProjectBranch(project, b -> b.setBranchType(BRANCH));
+ dbTester.getDbClient().branchDao().updateMeasuresMigrated(dbTester.getSession(), project.branchUuid(), true);
+ dbTester.getDbClient().branchDao().updateMeasuresMigrated(dbTester.getSession(), branch1.branchUuid(), true);
+ // 2 portfolios, all migrated
+ PortfolioDto portfolio1 = dbTester.components().insertPrivatePortfolioDto("name1");
+ PortfolioDto portfolio2 = dbTester.components().insertPrivatePortfolioDto("name2");
+ dbTester.getDbClient().portfolioDao().updateMeasuresMigrated(dbTester.getSession(), portfolio1.getUuid(), true);
+ dbTester.getDbClient().portfolioDao().updateMeasuresMigrated(dbTester.getSession(), portfolio2.getUuid(), true);
+ dbTester.commit();
+
+ TestResponse response = tester.newRequest()
+ .setParam(PARAM_SIZE, "2")
+ .execute();
+
+ assertThat(GSON.fromJson(response.getInput(), ActionResponse.class))
+ .isEqualTo(new ActionResponse("success", "0 branches or portfolios migrated", 0, 2, 0, 2));
+ verifyNoInteractions(measuresMigration, portfoliosMigration);
+ }
+
+ private void enableMigration() throws SQLException {
+ Database database = dbTester.getDbClient().getDatabase();
+ new AddMeasuresMigratedColumnToProjectBranchesTable(database).execute();
+ new AddMeasuresMigratedColumnToPortfoliosTable(database).execute();
+ dbTester.getDbClient().propertiesDao().saveProperty(new PropertyDto().setKey(SYSTEM_MEASURES_MIGRATION_ENABLED).setValue("true"));
+ }
+
+ @Test
+ public void throws_ForbiddenException_if_user_is_not_logged_in() {
+ userSessionRule.anonymous();
+
+ TestRequest request = tester.newRequest();
+
+ assertThatExceptionOfType(ForbiddenException.class)
+ .isThrownBy(request::execute);
+ }
+
+ @Test
+ public void throws_ForbiddenException_if_user_is_not_system_admin() {
+ userSessionRule.logIn();
+
+ TestRequest request = tester.newRequest();
+
+ assertThatExceptionOfType(ForbiddenException.class)
+ .isThrownBy(request::execute);
+ }
+
+ private record ActionResponse(String status, String message, int remainingBranches, int totalBranches, int remainingPortfolios,
+ int totalPortfolios) {
+ }
+}
diff --git a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/SystemWsModuleTest.java b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/SystemWsModuleTest.java
index 1249de66c19..8c7a40815c2 100644
--- a/server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/SystemWsModuleTest.java
+++ b/server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/SystemWsModuleTest.java
@@ -29,6 +29,6 @@ public class SystemWsModuleTest {
public void verify_count_of_added_components() {
ListContainer container = new ListContainer();
new SystemWsModule().configure(container);
- assertThat(container.getAddedObjects()).hasSize(21);
+ assertThat(container.getAddedObjects()).hasSize(24);
}
}