]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23213 'api/system/migrate_measures' endpoint
authorClaire Villard <claire.villard@sonarsource.com>
Tue, 1 Oct 2024 16:42:45 +0000 (18:42 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 14 Oct 2024 20:03:02 +0000 (20:03 +0000)
21 files changed:
server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/component/BranchMapper.java
server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioDao.java
server/sonar-db-dao/src/main/java/org/sonar/db/portfolio/PortfolioMapper.java
server/sonar-db-dao/src/main/resources/org/sonar/db/component/BranchMapper.xml
server/sonar-db-dao/src/main/resources/org/sonar/db/portfolio/PortfolioMapper.xml
server/sonar-db-dao/src/test/java/org/sonar/db/component/BranchDaoTest.java
server/sonar-db-dao/src/test/java/org/sonar/db/portfolio/PortfolioDaoTest.java
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/AbstractMigrateLiveMeasuresToMeasures.java [new file with mode: 0644]
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasures.java [new file with mode: 0644]
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasures.java [new file with mode: 0644]
server/sonar-db-migration/src/main/java/org/sonar/server/platform/db/migration/step/DataChange.java
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest.java [new file with mode: 0644]
server/sonar-db-migration/src/test/java/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest.java [new file with mode: 0644]
server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigrateBranchesLiveMeasuresToMeasuresTest/schema.sql [new file with mode: 0644]
server/sonar-db-migration/src/test/resources/org/sonar/server/platform/db/migration/adhoc/MigratePortfoliosLiveMeasuresToMeasuresTest/schema.sql [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/MigrateMeasuresAction.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/main/java/org/sonar/server/platform/ws/SystemWsModule.java
server/sonar-webserver-webapi/src/main/resources/org/sonar/server/platform/ws/example-migrate_measures.json [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/MigrateMeasuresActionTest.java [new file with mode: 0644]
server/sonar-webserver-webapi/src/test/java/org/sonar/server/platform/ws/SystemWsModuleTest.java

index 9bc71dc3534843e0cb82e6248181f085d29ea886..402e09abe5f63fc24da94ceeec8ec2c6757af96f 100644 (file)
@@ -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);
-  }
 }
index 24cdbacc68c60e91800571328ccaf84bcc14c64e..95adbb6224481762d60f23f53ec054ce551dd8f3 100644 (file)
@@ -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();
 }
index cbcfd25b3c6d7f8012bc38201a310aa09894020a..0a6863b6fe68101614ef15390c976c8c296301af 100644 (file)
@@ -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);
-  }
 }
index 2e273acc6d7754c80d093ae770e3c3f57e2ffa8a..1dbb66749bb6bd9695f31ae09a15d23e6ce0e1f4 100644 (file)
@@ -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);
 }
index eafb0b008cbefffed23994571c769b603a9ab258..238da88d1bd5422d6c1410cf466375c5bc7c1cca 100644 (file)
     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>
index 832fc96bb4afdd8383cb3ab7b9fa353508892087..db973bc0b50fea059a818892f81563b0714d31b0 100644 (file)
     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>
index 8f42fc67056f463073e3c659ab185cb8dc0f773f..f11f90f271c3d97933d50a6219b56dbd5cb37ae5 100644 (file)
@@ -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;
@@ -655,6 +653,70 @@ public class BranchDaoTest {
     assertThat(underTest.countByNeedIssueSync(dbSession, false)).isEqualTo(4);
   }
 
+  @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;
-  }
 }
index ae12b1b059d538bc94ec58e2a19f8b2e295847ec..9ea18c73c0e701a006f0415f768e9416e49e9b1f 100644 (file)
@@ -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 (file)
index 0000000..5135d7a
--- /dev/null
@@ -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 (file)
index 0000000..3138470
--- /dev/null
@@ -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 (file)
index 0000000..7ce1717
--- /dev/null
@@ -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");
+  }
+}
index 01380acffc0459eb74d135a6f8a2452ade86d0d0..216ba7a2cb089fe802da3b3c4d42704622a13e5d 100644 (file)
@@ -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 (file)
index 0000000..b7a251c
--- /dev/null
@@ -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 (file)
index 0000000..dd76bd3
--- /dev/null
@@ -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 (file)
index 0000000..4d3f9e7
--- /dev/null
@@ -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 (file)
index 0000000..2b4deb7
--- /dev/null
@@ -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 (file)
index 0000000..fd7d6b3
--- /dev/null
@@ -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) {
+  }
+
+}
index 5807d697a88a2652d728246dd59327810c907aab..41c21b03174d81e55953d046407822da533b9067 100644 (file)
@@ -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 (file)
index 0000000..60fb315
--- /dev/null
@@ -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 (file)
index 0000000..a4b9207
--- /dev/null
@@ -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) {
+  }
+}
index 1249de66c191b65c654ed0e53f94486f5d77869f..8c7a40815c2ea109a9bc819a4b60ff5909b8f047 100644 (file)
@@ -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);
   }
 }