]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-7305 Create WS api/measures/search_history
authorTeryk Bellahsene <teryk.bellahsene@sonarsource.com>
Tue, 24 Jan 2017 13:55:29 +0000 (14:55 +0100)
committerTeryk Bellahsene <teryk.bellahsene@sonarsource.com>
Tue, 24 Jan 2017 16:37:23 +0000 (17:37 +0100)
23 files changed:
it/it-tests/src/test/java/it/dbCleaner/PurgeTest.java
it/it-tests/src/test/java/it/measureHistory/TimeMachineTest.java
server/sonar-server/src/main/java/org/sonar/server/measure/ws/MeasuresWsModule.java
server/sonar-server/src/main/java/org/sonar/server/measure/ws/MetricDtoWithBestValue.java
server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryAction.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryResponseFactory.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryResult.java [new file with mode: 0644]
server/sonar-server/src/main/resources/org/sonar/server/measure/ws/search_history-example.json [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/measure/ws/MeasuresWsModuleTest.java
server/sonar-server/src/test/java/org/sonar/server/measure/ws/SearchHistoryActionTest.java [new file with mode: 0644]
sonar-db/src/main/java/org/sonar/db/component/SnapshotDto.java
sonar-db/src/main/java/org/sonar/db/measure/MeasureDao.java
sonar-db/src/main/java/org/sonar/db/measure/MeasureDto.java
sonar-db/src/main/java/org/sonar/db/measure/MeasureMapper.java
sonar-db/src/main/java/org/sonar/db/metric/MetricDtoFunctions.java
sonar-db/src/main/resources/org/sonar/db/measure/MeasureMapper.xml
sonar-db/src/test/java/org/sonar/db/measure/MeasureDaoTest.java
sonar-ws/src/main/java/org/sonarqube/ws/client/measure/MeasuresService.java
sonar-ws/src/main/java/org/sonarqube/ws/client/measure/MeasuresWsParameters.java
sonar-ws/src/main/java/org/sonarqube/ws/client/measure/SearchHistoryRequest.java [new file with mode: 0644]
sonar-ws/src/main/protobuf/ws-measures.proto
sonar-ws/src/test/java/org/sonarqube/ws/client/measure/MeasuresServiceTest.java
sonar-ws/src/test/java/org/sonarqube/ws/client/measure/SearchHistoryRequestTest.java [new file with mode: 0644]

index 73501238752aaabfe4f43608768988a590cac4cf..1095e5436671bcefe136c5435f39d0640d5ebb0c 100644 (file)
@@ -35,10 +35,12 @@ import org.junit.ClassRule;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.ErrorCollector;
-import org.sonarqube.ws.client.GetRequest;
-import org.sonarqube.ws.client.WsResponse;
+import org.sonarqube.ws.WsMeasures;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse.HistoryValue;
+import org.sonarqube.ws.client.measure.SearchHistoryRequest;
 import util.ItUtils;
 
+import static java.util.Collections.singletonList;
 import static org.apache.commons.lang.time.DateUtils.addDays;
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.hamcrest.Matchers.equalTo;
@@ -205,14 +207,12 @@ public class PurgeTest {
     runProjectAnalysis(orchestrator, PROJECT_SAMPLE_PATH);
 
     // Check that only analysis from last thursday is kept (as it's the last one from previous week)
-    WsResponse response = newAdminWsClient(orchestrator).wsConnector().call(
-      new GetRequest("/api/timemachine/index")
-        .setParam("resource", PROJECT_KEY)
-        .setParam("metrics", "ncloc"))
-      .failIfNotSuccessful();
-    String content = response.content();
-    assertThat(content).contains(lastThursdayFormatted);
-    assertThat(content).doesNotContain(lastWednesdayFormatted);
+    WsMeasures.SearchHistoryResponse response = newAdminWsClient(orchestrator).measures().searchHistory(SearchHistoryRequest.builder()
+      .setComponent(PROJECT_KEY)
+      .setMetrics(singletonList("ncloc"))
+      .build());
+    assertThat(response.getMeasuresCount()).isEqualTo(1);
+    assertThat(response.getMeasuresList().get(0).getHistoryList()).extracting(HistoryValue::getDate).doesNotContain(lastWednesdayFormatted, lastThursdayFormatted);
   }
 
   /**
@@ -279,7 +279,7 @@ public class PurgeTest {
     scan(PROJECT_SAMPLE_PATH, ONE_DAY_AGO);
 
     // second analysis as NEW_* metrics
-    assertThat(count(COUNT_FILE_MEASURES)).isLessThan( 2 * fileMeasures);
+    assertThat(count(COUNT_FILE_MEASURES)).isLessThan(2 * fileMeasures);
     assertThat(count(COUNT_DIR_MEASURES)).isGreaterThan(2 * dirMeasures);
   }
 
index 8e08021888078ea67d4bf794c296171e421e4d50..7c1434c68a292a83ff8cb31eeae2d60899983100 100644 (file)
@@ -24,33 +24,40 @@ import com.sonar.orchestrator.build.BuildResult;
 import com.sonar.orchestrator.build.SonarScanner;
 import com.sonar.orchestrator.locator.FileLocation;
 import it.Category1Suite;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.Map;
 import org.junit.AfterClass;
 import org.junit.BeforeClass;
 import org.junit.ClassRule;
 import org.junit.Test;
-import org.sonar.wsclient.services.TimeMachine;
-import org.sonar.wsclient.services.TimeMachineCell;
-import org.sonar.wsclient.services.TimeMachineQuery;
 import org.sonarqube.ws.WsMeasures.Measure;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse.HistoryValue;
+import org.sonarqube.ws.client.measure.MeasuresService;
+import org.sonarqube.ws.client.measure.SearchHistoryRequest;
 import util.ItUtils;
 import util.ItUtils.ComponentNavigation;
 
-import static java.lang.Double.parseDouble;
+import static java.util.Collections.singletonList;
 import static org.assertj.core.api.Assertions.assertThat;
+import static util.ItUtils.formatDate;
 import static util.ItUtils.getComponentNavigation;
 import static util.ItUtils.getMeasuresByMetricKey;
 import static util.ItUtils.getMeasuresWithVariationsByMetricKey;
+import static util.ItUtils.newAdminWsClient;
 import static util.ItUtils.projectDir;
 import static util.ItUtils.setServerProperty;
 
 public class TimeMachineTest {
 
   private static final String PROJECT = "sample";
+  private static final String FIRST_ANALYSIS_DATE = "2014-10-19";
+  private static final String SECOND_ANALYSIS_DATE = "2014-11-13";
 
   @ClassRule
   public static Orchestrator orchestrator = Category1Suite.ORCHESTRATOR;
+  private static MeasuresService wsMeasures;
 
   @BeforeClass
   public static void initialize() {
@@ -59,8 +66,10 @@ public class TimeMachineTest {
     orchestrator.getServer().restoreProfile(FileLocation.ofClasspath("/measureHistory/one-issue-per-line-profile.xml"));
     orchestrator.getServer().provisionProject("sample", "Sample");
     orchestrator.getServer().associateProjectToQualityProfile("sample", "xoo", "one-issue-per-line");
-    analyzeProject("measure/xoo-history-v1", "2014-10-19");
-    analyzeProject("measure/xoo-history-v2", "2014-11-13");
+    analyzeProject("measure/xoo-history-v1", FIRST_ANALYSIS_DATE);
+    analyzeProject("measure/xoo-history-v2", SECOND_ANALYSIS_DATE);
+
+    wsMeasures = newAdminWsClient(orchestrator).measures();
   }
 
   public static void initPeriods() {
@@ -87,73 +96,38 @@ public class TimeMachineTest {
 
   @Test
   public void testHistoryOfIssues() {
-    TimeMachineQuery query = TimeMachineQuery.createForMetrics(PROJECT, "blocker_violations", "critical_violations", "major_violations",
-      "minor_violations", "info_violations");
-    TimeMachine timemachine = orchestrator.getServer().getWsClient().find(query);
-    assertThat(timemachine.getCells().length).isEqualTo(2);
-
-    TimeMachineCell cell1 = timemachine.getCells()[0];
-    TimeMachineCell cell2 = timemachine.getCells()[1];
-
-    assertThat(cell1.getDate().getMonth()).isEqualTo(9);
-    assertThat(cell1.getValues()).isEqualTo(new Object[] {0L, 0L, 0L, 26L, 0L});
-
-    assertThat(cell2.getDate().getMonth()).isEqualTo(10);
-    assertThat(cell2.getValues()).isEqualTo(new Object[] {0L, 0L, 0L, 43L, 0L});
+    SearchHistoryResponse response = searchHistory("blocker_violations", "critical_violations", "info_violations", "major_violations", "minor_violations");
+    assertThat(response.getPaging().getTotal()).isEqualTo(2);
+
+    assertHistory(response, "blocker_violations", "0", "0");
+    assertHistory(response, "critical_violations", "0", "0");
+    assertHistory(response, "info_violations", "0", "0");
+    assertHistory(response, "major_violations", "0", "0");
+    assertHistory(response, "minor_violations", "26", "43");
   }
 
   @Test
   public void testHistoryOfMeasures() {
-    TimeMachineQuery query = TimeMachineQuery.createForMetrics(PROJECT, "lines", "ncloc");
-    TimeMachine timemachine = orchestrator.getServer().getWsClient().find(query);
-    assertThat(timemachine.getCells().length).isEqualTo(2);
-
-    TimeMachineCell cell1 = timemachine.getCells()[0];
-    TimeMachineCell cell2 = timemachine.getCells()[1];
-
-    assertThat(cell1.getDate().getMonth()).isEqualTo(9);
-    assertThat(cell1.getValues()).isEqualTo(new Object[] {26L, 24L});
-
-    assertThat(cell2.getDate().getMonth()).isEqualTo(10);
-    assertThat(cell2.getValues()).isEqualTo(new Object[] {43L, 40L});
-  }
-
-  @Test
-  public void unknownMetrics() {
-    TimeMachine timemachine = orchestrator.getServer().getWsClient().find(TimeMachineQuery.createForMetrics(PROJECT, "notfound"));
-    assertThat(timemachine.getCells().length).isEqualTo(0);
-
-    timemachine = orchestrator.getServer().getWsClient().find(TimeMachineQuery.createForMetrics(PROJECT, "lines", "notfound"));
-    assertThat(timemachine.getCells().length).isEqualTo(2);
-    for (TimeMachineCell cell : timemachine.getCells()) {
-      assertThat(cell.getValues().length).isEqualTo(1);
-      assertThat(cell.getValues()[0]).isInstanceOf(Long.class);
-    }
+    SearchHistoryResponse response = searchHistory("lines", "ncloc");
 
-    timemachine = orchestrator.getServer().getWsClient().find(TimeMachineQuery.createForMetrics(PROJECT));
-    assertThat(timemachine.getCells().length).isEqualTo(0);
+    assertThat(response.getPaging().getTotal()).isEqualTo(2);
+    assertHistory(response, "lines", "26", "43");
+    assertHistory(response, "ncloc", "24", "40");
   }
 
   @Test
   public void noDataForInterval() {
     Date now = new Date();
-    TimeMachine timemachine = orchestrator.getServer().getWsClient().find(TimeMachineQuery.createForMetrics(PROJECT, "lines").setFrom(now).setTo(now));
-    assertThat(timemachine.getCells().length).isEqualTo(0);
-  }
 
-  @Test
-  public void unknownResource() {
-    TimeMachine timemachine = orchestrator.getServer().getWsClient().find(TimeMachineQuery.createForMetrics("notfound:notfound", "lines"));
-    assertThat(timemachine).isNull();
-  }
+    SearchHistoryResponse response = wsMeasures.searchHistory(SearchHistoryRequest.builder()
+      .setComponent(PROJECT)
+      .setMetrics(singletonList("lines"))
+      .setFrom(formatDate(now))
+      .setTo(formatDate(now))
+      .build());
 
-  @Test
-  public void test_measure_variations() {
-    Map<String, Measure> measures = getMeasuresWithVariationsByMetricKey(orchestrator, PROJECT, "files", "ncloc", "violations");
-    // variations from previous analysis
-    assertThat(parseDouble(measures.get("files").getPeriods().getPeriodsValue(0).getValue())).isEqualTo(1.0);
-    assertThat(parseDouble(measures.get("ncloc").getPeriods().getPeriodsValue(0).getValue())).isEqualTo(16.0);
-    assertThat(parseDouble(measures.get("violations").getPeriods().getPeriodsValue(0).getValue())).isGreaterThan(0.0);
+    assertThat(response.getPaging().getTotal()).isEqualTo(0);
+    assertThat(response.getMeasures(0).getHistoryList()).isEmpty();
   }
 
   /**
@@ -164,9 +138,29 @@ public class TimeMachineTest {
     Map<String, Measure> measures = getMeasuresWithVariationsByMetricKey(orchestrator, PROJECT, "violations", "new_violations");
     assertThat(measures.get("violations")).isNotNull();
     assertThat(measures.get("new_violations")).isNotNull();
+    SearchHistoryResponse response = searchHistory("new_violations");
+    assertThat(response.getMeasures(0).getHistoryCount()).isGreaterThan(0);
 
     measures = getMeasuresByMetricKey(orchestrator, PROJECT, "violations", "new_violations");
     assertThat(measures.get("violations")).isNotNull();
     assertThat(measures.get("new_violations")).isNull();
   }
+
+  private static SearchHistoryResponse searchHistory(String... metrics) {
+    return wsMeasures.searchHistory(SearchHistoryRequest.builder()
+      .setComponent(PROJECT)
+      .setMetrics(Arrays.asList(metrics))
+      .build());
+  }
+
+  private static void assertHistory(SearchHistoryResponse response, String metric, String... expectedMeasures) {
+    for (SearchHistoryResponse.HistoryMeasure measures : response.getMeasuresList()) {
+      if (metric.equals(measures.getMetric())) {
+        assertThat(measures.getHistoryList()).extracting(HistoryValue::getValue).containsExactly(expectedMeasures);
+        return;
+      }
+    }
+
+    throw new IllegalArgumentException("Metric not found");
+  }
 }
index eeac683913a152cf9f0cd75ee6e34162e7ffbfb4..8cf0f5438bd621706d185ea2d331b7264baa2e03 100644 (file)
@@ -30,6 +30,7 @@ public class MeasuresWsModule extends Module {
       MeasuresWs.class,
       ComponentTreeAction.class,
       ComponentAction.class,
-      SearchAction.class);
+      SearchAction.class,
+      SearchHistoryAction.class);
   }
 }
index bef88a5bd8cc5795de77d0e11b1de0a2dddf5544..87f4aea7bd90e850bfd260756eb7f407dc4f5535 100644 (file)
  */
 package org.sonar.server.measure.ws;
 
+import com.google.common.collect.ImmutableSortedSet;
 import java.util.List;
 import java.util.Locale;
+import java.util.Set;
 import java.util.function.Function;
+import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import javax.annotation.Nonnull;
+import org.sonar.api.resources.Qualifiers;
+import org.sonar.db.component.ComponentDto;
 import org.sonar.db.measure.MeasureDto;
 import org.sonar.db.metric.MetricDto;
 import org.sonarqube.ws.WsMeasures;
 
 class MetricDtoWithBestValue {
   private static final String LOWER_CASE_NEW_METRIC_PREFIX = "new_";
+  private static final Set<String> QUALIFIERS_ELIGIBLE_FOR_BEST_VALUE = ImmutableSortedSet.of(Qualifiers.FILE, Qualifiers.UNIT_TEST_FILE);
 
   private final MetricDto metric;
   private final MeasureDto bestValue;
@@ -57,6 +63,10 @@ class MetricDtoWithBestValue {
     return bestValue;
   }
 
+  static Predicate<ComponentDto> isEligibleForBestValue() {
+    return component -> QUALIFIERS_ELIGIBLE_FOR_BEST_VALUE.contains(component.qualifier());
+  }
+
   static class MetricDtoToMetricDtoWithBestValueFunction implements Function<MetricDto, MetricDtoWithBestValue> {
     private final List<Integer> periodIndexes;
 
diff --git a/server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryAction.java b/server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryAction.java
new file mode 100644 (file)
index 0000000..8400bef
--- /dev/null
@@ -0,0 +1,179 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.measure.ws;
+
+import com.google.common.collect.Sets;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Stream;
+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.server.ws.WebService.Param;
+import org.sonar.api.web.UserRole;
+import org.sonar.core.util.stream.Collectors;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.component.SnapshotQuery;
+import org.sonar.db.component.SnapshotQuery.SORT_FIELD;
+import org.sonar.db.component.SnapshotQuery.SORT_ORDER;
+import org.sonar.db.measure.MeasureDto;
+import org.sonar.db.metric.MetricDto;
+import org.sonar.server.component.ComponentFinder;
+import org.sonar.server.user.UserSession;
+import org.sonar.server.ws.KeyExamples;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse;
+import org.sonarqube.ws.client.measure.SearchHistoryRequest;
+
+import static java.lang.String.format;
+import static org.sonar.api.utils.DateUtils.parseEndingDateOrDateTime;
+import static org.sonar.api.utils.DateUtils.parseStartingDateOrDateTime;
+import static org.sonar.core.util.Protobuf.setNullable;
+import static org.sonar.db.component.SnapshotDto.STATUS_PROCESSED;
+import static org.sonar.server.ws.WsUtils.writeProtobuf;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.ACTION_SEARCH_HISTORY;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_COMPONENT;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_FROM;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRICS;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_TO;
+import static org.sonarqube.ws.client.measure.SearchHistoryRequest.DEFAULT_PAGE_SIZE;
+import static org.sonarqube.ws.client.measure.SearchHistoryRequest.MAX_PAGE_SIZE;
+
+public class SearchHistoryAction implements MeasuresWsAction {
+  private final DbClient dbClient;
+  private final ComponentFinder componentFinder;
+  private final UserSession userSession;
+
+  public SearchHistoryAction(DbClient dbClient, ComponentFinder componentFinder, UserSession userSession) {
+    this.dbClient = dbClient;
+    this.componentFinder = componentFinder;
+    this.userSession = userSession;
+  }
+
+  @Override
+  public void define(WebService.NewController context) {
+    WebService.NewAction action = context.createAction(ACTION_SEARCH_HISTORY)
+      .setDescription("Search measures history of a component.<br>" +
+        "Measures are ordered chronologically.<br>" +
+        "Pagination applies to the number of measures for each metric.")
+      .setResponseExample(getClass().getResource("search_history-example.json"))
+      .setSince("6.3")
+      .setHandler(this);
+
+    action.createParam(PARAM_COMPONENT)
+      .setDescription("Component key")
+      .setRequired(true)
+      .setExampleValue(KeyExamples.KEY_PROJECT_EXAMPLE_001);
+
+    action.createParam(PARAM_METRICS)
+      .setDescription("Comma-separated list of metric keys")
+      .setRequired(true)
+      .setExampleValue("ncloc,coverage,new_violations");
+
+    action.createParam(PARAM_FROM)
+      .setDescription("Filter measures created after the given date (inclusive). Format: date or datetime ISO formats")
+      .setExampleValue("2013-05-01 (or 2013-05-01T13:00:00+0100)");
+
+    action.createParam(PARAM_TO)
+      .setDescription("Filter issues created before the given date (inclusive). Format: date or datetime ISO formats")
+      .setExampleValue("2013-05-01 (or 2013-05-01T13:00:00+0100)");
+
+    action.addPagingParams(DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE);
+  }
+
+  @Override
+  public void handle(Request request, Response response) throws Exception {
+    SearchHistoryResponse searchHistoryResponse = Stream.of(request)
+      .map(SearchHistoryAction::toWsRequest)
+      .map(search())
+      .map(result -> new SearchHistoryResponseFactory(result).apply())
+      .collect(Collectors.toOneElement());
+
+    writeProtobuf(searchHistoryResponse, request, response);
+  }
+
+  private Function<SearchHistoryRequest, SearchHistoryResult> search() {
+    return request -> {
+      try (DbSession dbSession = dbClient.openSession(false)) {
+        ComponentDto component = searchComponent(request, dbSession);
+
+        SearchHistoryResult result = new SearchHistoryResult(request)
+          .setComponent(component)
+          .setAnalyses(searchAnalyses(dbSession, request, component))
+          .setMetrics(searchMetrics(dbSession, request));
+        return result.setMeasures(searchMeasures(dbSession, component, result.getAnalyses(), result.getMetrics()));
+      }
+    };
+  }
+
+  private ComponentDto searchComponent(SearchHistoryRequest request, DbSession dbSession) {
+    ComponentDto component = componentFinder.getByKey(dbSession, request.getComponent());
+    userSession.checkComponentUuidPermission(UserRole.USER, component.projectUuid());
+    return component;
+  }
+
+  private List<MeasureDto> searchMeasures(DbSession dbSession, ComponentDto component, List<SnapshotDto> analyses, List<MetricDto> metrics) {
+    return dbClient.measureDao().selectPastMeasures(
+      dbSession,
+      component.uuid(),
+      analyses.stream().map(SnapshotDto::getUuid).collect(Collectors.toList()),
+      metrics.stream().map(MetricDto::getId).collect(Collectors.toList()));
+  }
+
+  private List<SnapshotDto> searchAnalyses(DbSession dbSession, SearchHistoryRequest request, ComponentDto component) {
+    SnapshotQuery dbQuery = new SnapshotQuery()
+      .setComponentUuid(component.projectUuid())
+      .setStatus(STATUS_PROCESSED)
+      .setSort(SORT_FIELD.BY_DATE, SORT_ORDER.ASC);
+    setNullable(request.getFrom(), from -> dbQuery.setCreatedAfter(parseStartingDateOrDateTime(from).getTime()));
+    setNullable(request.getTo(), to -> dbQuery.setCreatedBefore(parseEndingDateOrDateTime(to).getTime() + 1_000L));
+
+    return dbClient.snapshotDao().selectAnalysesByQuery(dbSession, dbQuery);
+  }
+
+  private List<MetricDto> searchMetrics(DbSession dbSession, SearchHistoryRequest request) {
+    List<MetricDto> metrics = dbClient.metricDao().selectByKeys(dbSession, request.getMetrics());
+    if (request.getMetrics().size() > metrics.size()) {
+      Set<String> requestedMetrics = request.getMetrics().stream().collect(Collectors.toSet());
+      Set<String> foundMetrics = metrics.stream().map(MetricDto::getKey).collect(Collectors.toSet());
+
+      Set<String> unfoundMetrics = Sets.difference(requestedMetrics, foundMetrics).immutableCopy();
+      throw new IllegalArgumentException(format("Metrics %s are not found", String.join(", ", unfoundMetrics)));
+    }
+
+    return metrics;
+  }
+
+  private static SearchHistoryRequest toWsRequest(Request request) {
+    return SearchHistoryRequest.builder()
+      .setComponent(request.mandatoryParam(PARAM_COMPONENT))
+      .setMetrics(request.mandatoryParamAsStrings(PARAM_METRICS))
+      .setFrom(request.param(PARAM_FROM))
+      .setTo(request.param(PARAM_TO))
+      .setPage(request.mandatoryParamAsInt(Param.PAGE))
+      .setPageSize(request.mandatoryParamAsInt(Param.PAGE_SIZE))
+      .build();
+  }
+
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryResponseFactory.java b/server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryResponseFactory.java
new file mode 100644 (file)
index 0000000..7fd49fc
--- /dev/null
@@ -0,0 +1,118 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.measure.ws;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.Table;
+import java.util.Map;
+import java.util.function.Predicate;
+import java.util.function.UnaryOperator;
+import java.util.stream.Stream;
+import org.sonar.core.util.stream.Collectors;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.measure.MeasureDto;
+import org.sonar.db.metric.MetricDto;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse.HistoryMeasure;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse.HistoryValue;
+
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.server.measure.ws.MeasureValueFormatter.formatMeasureValue;
+import static org.sonar.server.measure.ws.MeasureValueFormatter.formatNumericalValue;
+
+class SearchHistoryResponseFactory {
+  private final SearchHistoryResult result;
+  private final HistoryMeasure.Builder measure;
+  private final HistoryValue.Builder value;
+
+  SearchHistoryResponseFactory(SearchHistoryResult result) {
+    this.result = result;
+    this.measure = HistoryMeasure.newBuilder();
+    this.value = HistoryValue.newBuilder();
+  }
+
+  public SearchHistoryResponse apply() {
+    return Stream.of(SearchHistoryResponse.newBuilder())
+      .map(addPaging())
+      .map(addMeasures())
+      .map(SearchHistoryResponse.Builder::build)
+      .collect(Collectors.toOneElement());
+  }
+
+  private UnaryOperator<SearchHistoryResponse.Builder> addPaging() {
+    return response -> response.setPaging(result.getPaging());
+  }
+
+  private UnaryOperator<SearchHistoryResponse.Builder> addMeasures() {
+    Map<Integer, MetricDto> metricsById = result.getMetrics().stream().collect(Collectors.uniqueIndex(MetricDto::getId));
+    Map<String, SnapshotDto> analysesByUuid = result.getAnalyses().stream().collect(Collectors.uniqueIndex(SnapshotDto::getUuid));
+    Table<MetricDto, SnapshotDto, MeasureDto> measuresByMetricByAnalysis = HashBasedTable.create(result.getMetrics().size(), result.getAnalyses().size());
+    result.getMeasures().forEach(m -> measuresByMetricByAnalysis.put(metricsById.get(m.getMetricId()), analysesByUuid.get(m.getAnalysisUuid()), m));
+
+    return response -> {
+      result.getMetrics().stream()
+        .peek(metric -> measure.clear())
+        .map(addMetric())
+        .map(metric -> addValues(measuresByMetricByAnalysis.row(metric)).apply(metric))
+        .forEach(metric -> response.addMeasures(measure));
+
+      return response;
+    };
+  }
+
+  private UnaryOperator<MetricDto> addMetric() {
+    return metric -> {
+      measure.setMetric(metric.getKey());
+      return metric;
+    };
+  }
+
+  private UnaryOperator<MetricDto> addValues(Map<SnapshotDto, MeasureDto> measuresByAnalysis) {
+    Predicate<SnapshotDto> hasMeasure = analysis -> measuresByAnalysis.get(analysis) != null;
+    return metric -> {
+      result.getAnalyses().stream()
+        .filter(hasMeasure)
+        .peek(analysis -> value.clear())
+        .map(addDate())
+        .map(analysis -> addValue(metric, measuresByAnalysis.get(analysis)).apply(analysis))
+        .forEach(analysis -> measure.addHistory(value));
+
+      return metric;
+    };
+  }
+
+  private UnaryOperator<SnapshotDto> addDate() {
+    return analysis -> {
+      value.setDate(formatDateTime(analysis.getCreatedAt()));
+      return analysis;
+    };
+  }
+
+  private UnaryOperator<SnapshotDto> addValue(MetricDto dbMetric, MeasureDto dbMeasure) {
+    return analysis -> {
+      String measureValue = dbMetric.getKey().startsWith("new_")
+        ? formatNumericalValue(dbMeasure.getVariation(1), dbMetric)
+        : formatMeasureValue(dbMeasure, dbMetric);
+      value.setValue(measureValue);
+      return analysis;
+    };
+  }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryResult.java b/server/sonar-server/src/main/java/org/sonar/server/measure/ws/SearchHistoryResult.java
new file mode 100644 (file)
index 0000000..3d23e3c
--- /dev/null
@@ -0,0 +1,139 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.measure.ws;
+
+import com.google.common.collect.HashBasedTable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Table;
+import java.util.ArrayList;
+import java.util.List;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.measure.MeasureDto;
+import org.sonar.db.metric.MetricDto;
+import org.sonarqube.ws.Common;
+import org.sonarqube.ws.client.measure.SearchHistoryRequest;
+
+import static java.util.Collections.emptyList;
+import static java.util.Objects.requireNonNull;
+import static org.sonar.api.utils.Paging.offset;
+import static org.sonar.core.util.stream.Collectors.toList;
+import static org.sonar.db.metric.MetricDtoFunctions.isOptimizedForBestValue;
+import static org.sonar.server.measure.ws.MetricDtoWithBestValue.isEligibleForBestValue;
+
+class SearchHistoryResult {
+  private final SearchHistoryRequest request;
+  private List<SnapshotDto> analyses;
+  private List<MetricDto> metrics;
+  private List<MeasureDto> measures;
+  private Common.Paging paging;
+  private ComponentDto component;
+
+  SearchHistoryResult(SearchHistoryRequest request) {
+    this.request = request;
+  }
+
+  boolean hasResults() {
+    return !analyses.isEmpty();
+  }
+
+  SearchHistoryResult setComponent(ComponentDto component) {
+    this.component = component;
+
+    return this;
+  }
+
+  List<SnapshotDto> getAnalyses() {
+    return requireNonNull(analyses);
+  }
+
+  SearchHistoryResult setAnalyses(List<SnapshotDto> analyses) {
+    this.paging = Common.Paging.newBuilder().setPageIndex(request.getPage()).setPageSize(request.getPageSize()).setTotal(analyses.size()).build();
+    this.analyses = analyses.stream().skip(offset(request.getPage(), request.getPageSize())).limit(request.getPageSize()).collect(toList());
+    return this;
+  }
+
+  List<MetricDto> getMetrics() {
+    return requireNonNull(metrics);
+  }
+
+  SearchHistoryResult setMetrics(List<MetricDto> metrics) {
+    this.metrics = metrics;
+    return this;
+  }
+
+  List<MeasureDto> getMeasures() {
+    return requireNonNull(measures);
+  }
+
+  SearchHistoryResult setMeasures(List<MeasureDto> measures) {
+    this.measures = ImmutableList.<MeasureDto>builder()
+      .addAll(measures)
+      .addAll(addBestValuesToMeasures(component, measures)).build();
+    return this;
+  }
+
+  /**
+   * Conditions for best value measure:
+   * <ul>
+   * <li>component is a production file or test file</li>
+   * <li>metric is optimized for best value</li>
+   * </ul>
+   */
+  private List<MeasureDto> addBestValuesToMeasures(ComponentDto component, List<MeasureDto> measures) {
+    if (!isEligibleForBestValue().test(component)) {
+      return emptyList();
+    }
+
+    requireNonNull(metrics);
+    requireNonNull(analyses);
+
+    Table<Integer, String, MeasureDto> measuresByMetricIdAndAnalysisUuid = HashBasedTable.create(metrics.size(), analyses.size());
+    measures.forEach(measure -> measuresByMetricIdAndAnalysisUuid.put(measure.getMetricId(), measure.getAnalysisUuid(), measure));
+    List<MeasureDto> bestValues = new ArrayList<>();
+    metrics.stream()
+      .filter(isOptimizedForBestValue())
+      .forEach(metric -> analyses.stream()
+        .filter(analysis -> !measuresByMetricIdAndAnalysisUuid.contains(metric.getId(), analysis.getUuid()))
+        .map(analysis -> toBestValue(metric, analysis))
+        .forEach(bestValues::add));
+
+    return bestValues;
+  }
+
+  private static MeasureDto toBestValue(MetricDto metric, SnapshotDto analysis) {
+    MeasureDto measure = new MeasureDto()
+      .setMetricId(metric.getId())
+      .setAnalysisUuid(analysis.getUuid());
+
+    if (metric.getKey().startsWith("new_")) {
+      measure.setVariation(1, metric.getBestValue());
+    } else {
+      measure.setValue(metric.getBestValue());
+    }
+
+    return measure;
+  }
+
+  Common.Paging getPaging() {
+    return requireNonNull(paging);
+  }
+}
diff --git a/server/sonar-server/src/main/resources/org/sonar/server/measure/ws/search_history-example.json b/server/sonar-server/src/main/resources/org/sonar/server/measure/ws/search_history-example.json
new file mode 100644 (file)
index 0000000..5e0f96f
--- /dev/null
@@ -0,0 +1,60 @@
+{
+  "paging": {
+    "pageIndex": 1,
+    "pageSize": 100,
+    "total": 3
+  },
+  "measures": [
+    {
+      "metric": "complexity",
+      "history": [
+        {
+          "date": "2017-01-23T17:00:53+0100",
+          "value": "45"
+        },
+        {
+          "date": "2017-01-24T17:00:53+0100",
+          "value": "45"
+        },
+        {
+          "date": "2017-01-25T17:00:53+0100",
+          "value": "45"
+        }
+      ]
+    },
+    {
+      "metric": "ncloc",
+      "history": [
+        {
+          "date": "2017-01-23T17:00:53+0100",
+          "value": "47"
+        },
+        {
+          "date": "2017-01-24T17:00:53+0100",
+          "value": "47"
+        },
+        {
+          "date": "2017-01-25T17:00:53+0100",
+          "value": "47"
+        }
+      ]
+    },
+    {
+      "metric": "new_violations",
+      "history": [
+        {
+          "date": "2017-01-23T17:00:53+0100",
+          "value": "46"
+        },
+        {
+          "date": "2017-01-24T17:00:53+0100",
+          "value": "46"
+        },
+        {
+          "date": "2017-01-25T17:00:53+0100",
+          "value": "46"
+        }
+      ]
+    }
+  ]
+}
index c5a2b957d11ca5d04c7a69e0c50a7c0a2acadd22..cfe934435bd6bcf1455e63cee21c4955304cde4f 100644 (file)
@@ -29,6 +29,6 @@ public class MeasuresWsModuleTest {
   public void verify_count_of_added_components() {
     ComponentContainer container = new ComponentContainer();
     new MeasuresWsModule().configure(container);
-    assertThat(container.size()).isEqualTo(5 + 2);
+    assertThat(container.size()).isEqualTo(6 + 2);
   }
 }
diff --git a/server/sonar-server/src/test/java/org/sonar/server/measure/ws/SearchHistoryActionTest.java b/server/sonar-server/src/test/java/org/sonar/server/measure/ws/SearchHistoryActionTest.java
new file mode 100644 (file)
index 0000000..0b6e270
--- /dev/null
@@ -0,0 +1,390 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.measure.ws;
+
+import com.google.common.base.Throwables;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.LongStream;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.measures.Metric.ValueType;
+import org.sonar.api.server.ws.WebService;
+import org.sonar.api.server.ws.WebService.Param;
+import org.sonar.api.utils.System2;
+import org.sonar.api.web.UserRole;
+import org.sonar.core.util.stream.Collectors;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.DbTester;
+import org.sonar.db.component.ComponentDto;
+import org.sonar.db.component.SnapshotDto;
+import org.sonar.db.metric.MetricDto;
+import org.sonar.server.component.ComponentFinder;
+import org.sonar.server.exceptions.ForbiddenException;
+import org.sonar.server.exceptions.NotFoundException;
+import org.sonar.server.tester.UserSessionRule;
+import org.sonar.server.ws.TestRequest;
+import org.sonar.server.ws.WsActionTester;
+import org.sonarqube.ws.Common.Paging;
+import org.sonarqube.ws.MediaTypes;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse.HistoryMeasure;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse.HistoryValue;
+import org.sonarqube.ws.client.measure.SearchHistoryRequest;
+
+import static com.google.common.collect.Lists.newArrayList;
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.tuple;
+import static org.sonar.api.utils.DateUtils.formatDateTime;
+import static org.sonar.api.utils.DateUtils.parseDateTime;
+import static org.sonar.core.util.Protobuf.setNullable;
+import static org.sonar.db.component.ComponentTesting.newFileDto;
+import static org.sonar.db.component.ComponentTesting.newProjectDto;
+import static org.sonar.db.component.SnapshotDto.STATUS_UNPROCESSED;
+import static org.sonar.db.component.SnapshotTesting.newAnalysis;
+import static org.sonar.db.measure.MeasureTesting.newMeasureDto;
+import static org.sonar.db.metric.MetricTesting.newMetricDto;
+import static org.sonar.test.JsonAssert.assertJson;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_COMPONENT;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_FROM;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRICS;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_TO;
+
+public class SearchHistoryActionTest {
+
+  @Rule
+  public UserSessionRule userSession = UserSessionRule.standalone();
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+  @Rule
+  public DbTester db = DbTester.create();
+  private DbClient dbClient = db.getDbClient();
+  private DbSession dbSession = db.getSession();
+
+  private WsActionTester ws = new WsActionTester(new SearchHistoryAction(dbClient, new ComponentFinder(dbClient), userSession));
+
+  private ComponentDto project;
+  private SnapshotDto analysis;
+  private List<String> metrics;
+  private MetricDto complexityMetric;
+  private MetricDto nclocMetric;
+  private MetricDto newViolationMetric;
+  private SearchHistoryRequest.Builder wsRequest;
+
+  @Before
+  public void setUp() {
+    project = newProjectDto(db.getDefaultOrganization());
+    analysis = db.components().insertProjectAndSnapshot(project);
+    userSession.addProjectUuidPermissions(UserRole.USER, project.uuid());
+    nclocMetric = insertNclocMetric();
+    complexityMetric = insertComplexityMetric();
+    newViolationMetric = insertNewViolationMetric();
+    metrics = newArrayList(nclocMetric.getKey(), complexityMetric.getKey(), newViolationMetric.getKey());
+    wsRequest = SearchHistoryRequest.builder().setComponent(project.getKey()).setMetrics(metrics);
+  }
+
+  @Test
+  public void empty_response() {
+    project = db.components().insertProject();
+    userSession.addProjectUuidPermissions(UserRole.USER, project.uuid());
+    wsRequest
+      .setComponent(project.getKey())
+      .setMetrics(singletonList(complexityMetric.getKey()));
+
+    SearchHistoryResponse result = call();
+
+    assertThat(result.getMeasuresList()).hasSize(1);
+    assertThat(result.getMeasures(0).getHistoryCount()).isEqualTo(0);
+
+    assertThat(result.getPaging()).extracting(Paging::getPageIndex, Paging::getPageSize, Paging::getTotal)
+      // pagination is applied to the number of analyses
+      .containsExactly(1, 100, 0);
+  }
+
+  @Test
+  public void return_metrics() {
+    dbClient.measureDao().insert(dbSession, newMeasureDto(complexityMetric, project, analysis).setValue(42.0d));
+    db.commit();
+
+    SearchHistoryResponse result = call();
+
+    assertThat(result.getMeasuresList()).hasSize(3)
+      .extracting(HistoryMeasure::getMetric)
+      .containsExactly(complexityMetric.getKey(), nclocMetric.getKey(), newViolationMetric.getKey());
+  }
+
+  @Test
+  public void return_measures() {
+    SnapshotDto laterAnalysis = dbClient.snapshotDao().insert(dbSession, newAnalysis(project).setCreatedAt(analysis.getCreatedAt() + 42_000));
+    ComponentDto file = db.components().insertComponent(newFileDto(project));
+    dbClient.measureDao().insert(dbSession,
+      newMeasureDto(complexityMetric, project, analysis).setValue(101d),
+      newMeasureDto(complexityMetric, project, laterAnalysis).setValue(100d),
+      newMeasureDto(complexityMetric, file, analysis).setValue(42d),
+      newMeasureDto(nclocMetric, project, analysis).setValue(201d),
+      newMeasureDto(newViolationMetric, project, analysis).setVariation(1, 5d),
+      newMeasureDto(newViolationMetric, project, laterAnalysis).setVariation(1, 10d));
+    db.commit();
+
+    SearchHistoryResponse result = call();
+
+    assertThat(result.getPaging()).extracting(Paging::getPageIndex, Paging::getPageSize, Paging::getTotal)
+      .containsExactly(1, 100, 2);
+    assertThat(result.getMeasuresList()).extracting(HistoryMeasure::getMetric).hasSize(3)
+      .containsExactly(complexityMetric.getKey(), nclocMetric.getKey(), newViolationMetric.getKey());
+    String analysisDate = formatDateTime(analysis.getCreatedAt());
+    String laterAnalysisDate = formatDateTime(laterAnalysis.getCreatedAt());
+    // complexity measures
+    HistoryMeasure complexityMeasures = result.getMeasures(0);
+    assertThat(complexityMeasures.getMetric()).isEqualTo(complexityMetric.getKey());
+    assertThat(complexityMeasures.getHistoryList()).extracting(HistoryValue::getDate, HistoryValue::getValue)
+      .containsExactly(tuple(analysisDate, "101"), tuple(laterAnalysisDate, "100"));
+    // ncloc measures
+    HistoryMeasure nclocMeasures = result.getMeasures(1);
+    assertThat(nclocMeasures.getMetric()).isEqualTo(nclocMetric.getKey());
+    assertThat(nclocMeasures.getHistoryList()).extracting(HistoryValue::getDate, HistoryValue::getValue)
+      .containsExactly(tuple(analysisDate, "201"));
+    // new_violation measures
+    HistoryMeasure newViolationMeasures = result.getMeasures(2);
+    assertThat(newViolationMeasures.getMetric()).isEqualTo(newViolationMetric.getKey());
+    assertThat(newViolationMeasures.getHistoryList()).extracting(HistoryValue::getDate, HistoryValue::getValue)
+      .containsExactly(tuple(analysisDate, "5"), tuple(laterAnalysisDate, "10"));
+  }
+
+  @Test
+  public void pagination_applies_to_analyses() {
+    project = db.components().insertProject();
+    userSession.addProjectUuidPermissions(UserRole.USER, project.uuid());
+    List<String> analysisDates = LongStream.rangeClosed(1, 9)
+      .mapToObj(i -> dbClient.snapshotDao().insert(dbSession, newAnalysis(project).setCreatedAt(i * 1_000_000_000)))
+      .peek(a -> dbClient.measureDao().insert(dbSession, newMeasureDto(complexityMetric, project, a).setValue(101d)))
+      .map(a -> formatDateTime(a.getCreatedAt()))
+      .collect(Collectors.toList());
+    db.commit();
+    wsRequest.setComponent(project.getKey()).setPage(2).setPageSize(3);
+
+    SearchHistoryResponse result = call();
+
+    assertThat(result.getPaging()).extracting(Paging::getPageIndex, Paging::getPageSize, Paging::getTotal).containsExactly(2, 3, 9);
+    assertThat(result.getMeasures(0).getHistoryList()).extracting(HistoryValue::getDate).containsExactly(
+      analysisDates.get(3), analysisDates.get(4), analysisDates.get(5));
+  }
+
+  @Test
+  public void inclusive_from_and_to_dates() {
+    project = db.components().insertProject();
+    userSession.addProjectUuidPermissions(UserRole.USER, project.uuid());
+    List<String> analysisDates = LongStream.rangeClosed(1, 9)
+      .mapToObj(i -> dbClient.snapshotDao().insert(dbSession, newAnalysis(project).setCreatedAt(System2.INSTANCE.now() + i * 1_000_000_000L)))
+      .peek(a -> dbClient.measureDao().insert(dbSession, newMeasureDto(complexityMetric, project, a).setValue(Double.valueOf(a.getCreatedAt()))))
+      .map(a -> formatDateTime(a.getCreatedAt()))
+      .collect(Collectors.toList());
+    db.commit();
+    wsRequest.setComponent(project.getKey()).setFrom(analysisDates.get(1)).setTo(analysisDates.get(3));
+
+    SearchHistoryResponse result = call();
+
+    assertThat(result.getPaging()).extracting(Paging::getPageIndex, Paging::getPageSize, Paging::getTotal).containsExactly(1, 100, 3);
+    assertThat(result.getMeasures(0).getHistoryList()).extracting(HistoryValue::getDate).containsExactly(
+      analysisDates.get(1), analysisDates.get(2), analysisDates.get(3));
+  }
+
+  @Test
+  public void return_best_values_for_files() {
+    dbClient.metricDao().insert(dbSession, newMetricDto().setKey("optimized").setValueType(ValueType.INT.name()).setOptimizedBestValue(true).setBestValue(456d));
+    dbClient.metricDao().insert(dbSession, newMetricDto().setKey("new_optimized").setValueType(ValueType.INT.name()).setOptimizedBestValue(true).setBestValue(789d));
+    db.commit();
+    ComponentDto file = db.components().insertComponent(newFileDto(project));
+    wsRequest.setComponent(file.getKey()).setMetrics(Arrays.asList("optimized", "new_optimized"));
+
+    SearchHistoryResponse result = call();
+
+    assertThat(result.getMeasuresCount()).isEqualTo(2);
+    assertThat(result.getMeasuresList().get(0).getHistoryList()).extracting(HistoryValue::getValue).containsExactly("789");
+    assertThat(result.getMeasuresList().get(1).getHistoryList()).extracting(HistoryValue::getValue).containsExactly("456");
+
+    // Best value is not applied to project
+    wsRequest.setComponent(project.getKey());
+    result = call();
+    assertThat(result.getMeasuresList().get(0).getHistoryCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void do_not_return_unprocessed_analyses() {
+    dbClient.snapshotDao().insert(dbSession, newAnalysis(project).setStatus(STATUS_UNPROCESSED));
+    db.commit();
+
+    SearchHistoryResponse result = call();
+
+    // one analysis in setUp method
+    assertThat(result.getPaging().getTotal()).isEqualTo(1);
+  }
+
+  @Test
+  public void do_not_return_developer_measures() {
+    wsRequest.setMetrics(singletonList(complexityMetric.getKey()));
+    dbClient.measureDao().insert(dbSession, newMeasureDto(complexityMetric, project, analysis).setDeveloperId(42L));
+    db.commit();
+
+    SearchHistoryResponse result = call();
+
+    assertThat(result.getMeasuresCount()).isEqualTo(1);
+    assertThat(result.getMeasures(0).getHistoryCount()).isEqualTo(0);
+  }
+
+  @Test
+  public void fail_if_unknown_metric() {
+    wsRequest.setMetrics(newArrayList(complexityMetric.getKey(), nclocMetric.getKey(), "METRIC_42", "42_METRIC"));
+
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Metrics 42_METRIC, METRIC_42 are not found");
+
+    call();
+  }
+
+  @Test
+  public void fail_if_not_enough_permissions() {
+    userSession.login().addProjectUuidPermissions(UserRole.ADMIN, project.uuid());
+
+    expectedException.expect(ForbiddenException.class);
+
+    call();
+  }
+
+  @Test
+  public void fail_if_unknown_component() {
+    wsRequest.setComponent("PROJECT_42");
+
+    expectedException.expect(NotFoundException.class);
+
+    call();
+  }
+
+  @Test
+  public void definition() {
+    WebService.Action definition = ws.getDef();
+
+    assertThat(definition.key()).isEqualTo("search_history");
+    assertThat(definition.responseExampleAsString()).isNotEmpty();
+    assertThat(definition.isPost()).isFalse();
+    assertThat(definition.isInternal()).isFalse();
+    assertThat(definition.since()).isEqualTo("6.3");
+  }
+
+  @Test
+  public void json_example() {
+    project = db.components().insertProject();
+    userSession.addProjectUuidPermissions(UserRole.USER, project.uuid());
+    long now = parseDateTime("2017-01-23T17:00:53+0100").getTime();
+    LongStream.rangeClosed(0, 2)
+      .mapToObj(i -> dbClient.snapshotDao().insert(dbSession, newAnalysis(project).setCreatedAt(now + i * 24 * 1_000 * 60 * 60)))
+      .forEach(analysis -> dbClient.measureDao().insert(dbSession,
+        newMeasureDto(complexityMetric, project, analysis).setValue(45d),
+        newMeasureDto(newViolationMetric, project, analysis).setVariation(1, 46d),
+        newMeasureDto(nclocMetric, project, analysis).setValue(47d)));
+    db.commit();
+
+    String result = ws.newRequest()
+      .setParam(PARAM_COMPONENT, project.getKey())
+      .setParam(PARAM_METRICS, String.join(",", metrics))
+      .execute().getInput();
+
+    assertJson(result).isSimilarTo(ws.getDef().responseExampleAsString());
+  }
+
+  private SearchHistoryResponse call() {
+    SearchHistoryRequest wsRequest = this.wsRequest.build();
+
+    TestRequest request = ws.newRequest()
+      .setMediaType(MediaTypes.PROTOBUF);
+
+    request.setParam(PARAM_COMPONENT, wsRequest.getComponent());
+    request.setParam(PARAM_METRICS, String.join(",", wsRequest.getMetrics()));
+    setNullable(wsRequest.getFrom(), from -> request.setParam(PARAM_FROM, from));
+    setNullable(wsRequest.getTo(), to -> request.setParam(PARAM_TO, to));
+    setNullable(wsRequest.getPage(), p -> request.setParam(Param.PAGE, String.valueOf(p)));
+    setNullable(wsRequest.getPageSize(), ps -> request.setParam(Param.PAGE_SIZE, String.valueOf(ps)));
+
+    try {
+      return SearchHistoryResponse.parseFrom(request.execute().getInputStream());
+    } catch (IOException e) {
+      throw Throwables.propagate(e);
+    }
+  }
+
+  private static MetricDto newMetricDtoWithoutOptimization() {
+    return newMetricDto()
+      .setWorstValue(null)
+      .setOptimizedBestValue(false)
+      .setBestValue(null)
+      .setUserManaged(false);
+  }
+
+  private MetricDto insertNclocMetric() {
+    MetricDto metric = dbClient.metricDao().insert(dbSession, newMetricDtoWithoutOptimization()
+      .setKey("ncloc")
+      .setShortName("Lines of code")
+      .setDescription("Non Commenting Lines of Code")
+      .setDomain("Size")
+      .setValueType("INT")
+      .setDirection(-1)
+      .setQualitative(false)
+      .setHidden(false)
+      .setUserManaged(false));
+    db.commit();
+    return metric;
+  }
+
+  private MetricDto insertComplexityMetric() {
+    MetricDto metric = dbClient.metricDao().insert(dbSession, newMetricDtoWithoutOptimization()
+      .setKey("complexity")
+      .setShortName("Complexity")
+      .setDescription("Cyclomatic complexity")
+      .setDomain("Complexity")
+      .setValueType("INT")
+      .setDirection(-1)
+      .setQualitative(false)
+      .setHidden(false)
+      .setUserManaged(false));
+    db.commit();
+    return metric;
+  }
+
+  private MetricDto insertNewViolationMetric() {
+    MetricDto metric = dbClient.metricDao().insert(dbSession, newMetricDtoWithoutOptimization()
+      .setKey("new_violations")
+      .setShortName("New issues")
+      .setDescription("New Issues")
+      .setDomain("Issues")
+      .setValueType("INT")
+      .setDirection(-1)
+      .setQualitative(true)
+      .setHidden(false)
+      .setUserManaged(false));
+    db.commit();
+    return metric;
+  }
+}
index c84b1176211c04faa80cde7636644d71f1ed4c21..635b911849684ab7aa7e505b6134ef740386b64b 100644 (file)
@@ -265,6 +265,9 @@ public final class SnapshotDto {
     return this;
   }
 
+  /**
+   * @return analysis date
+   */
   public Long getCreatedAt() {
     return createdAt;
   }
index 0daf18d49b1819f58707229c653ab02ad0b6d8e6..5539c79a73d9d3030ecd48b828eca096fda14f86 100644 (file)
@@ -22,7 +22,6 @@ package org.sonar.db.measure;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import org.apache.ibatis.session.ResultHandler;
@@ -30,6 +29,7 @@ import org.sonar.db.Dao;
 import org.sonar.db.DbSession;
 import org.sonar.db.component.ComponentDto;
 
+import static java.util.Collections.emptyList;
 import static org.sonar.db.DatabaseUtils.executeLargeInputs;
 import static org.sonar.db.DatabaseUtils.executeLargeInputsWithoutOutput;
 
@@ -58,7 +58,7 @@ public class MeasureDao implements Dao {
    */
   public List<MeasureDto> selectByQuery(DbSession dbSession, MeasureQuery query) {
     if (query.returnsEmpty()) {
-      return Collections.emptyList();
+      return emptyList();
     }
     if (query.isOnComponents()) {
       return executeLargeInputs(
@@ -106,18 +106,28 @@ public class MeasureDao implements Dao {
 
   public List<MeasureDto> selectTreeByQuery(DbSession dbSession, ComponentDto baseComponent, MeasureTreeQuery query) {
     if (query.returnsEmpty()) {
-      return Collections.emptyList();
+      return emptyList();
     }
     return mapper(dbSession).selectTreeByQuery(query, baseComponent.uuid(), query.getUuidPath(baseComponent));
   }
 
-  public List<PastMeasureDto> selectPastMeasures(DbSession dbSession,
-    String componentUuid,
-    String analysisUuid,
-    Collection<Integer> metricIds) {
+  public List<PastMeasureDto> selectPastMeasures(DbSession dbSession, String componentUuid, String analysisUuid, Collection<Integer> metricIds) {
+    if (metricIds.isEmpty()) {
+      return emptyList();
+    }
     return executeLargeInputs(
       metricIds,
-      ids -> mapper(dbSession).selectPastMeasures(componentUuid, analysisUuid, ids));
+      ids -> mapper(dbSession).selectPastMeasuresOnSingleAnalysis(componentUuid, analysisUuid, ids));
+  }
+
+  public List<MeasureDto> selectPastMeasures(DbSession dbSession, String componentUuid, List<String> analysisUuids, List<Integer> metricIds) {
+    if (analysisUuids.isEmpty() || metricIds.isEmpty()) {
+      return emptyList();
+    }
+
+    return executeLargeInputs(
+      analysisUuids,
+      analyses -> mapper(dbSession).selectPastMeasuresOnSeveralAnalyses(componentUuid, analyses, metricIds));
   }
 
   /**
index ff4bd8066031e371c141f546e99419649bd7622b..9e41defc058584b148f825b1fd023a26e7aeb2b7 100644 (file)
@@ -86,6 +86,9 @@ public class MeasureDto {
     return this;
   }
 
+  /**
+   * @param index starts at 1
+   */
   @CheckForNull
   public Double getVariation(int index) {
     switch (index) {
index 978b8b0c47c3b6b67bcce8be7d263439210a2b2f..eb282770edc71bf52aa214d2eb4b58afea11f5df 100644 (file)
@@ -40,7 +40,11 @@ public interface MeasureMapper {
 
   List<MeasureDto> selectTreeByQuery(@Param("query") MeasureTreeQuery measureQuery, @Param("baseUuid") String baseUuid, @Param("baseUuidPath") String baseUuidPath);
 
-  List<PastMeasureDto> selectPastMeasures(@Param("componentUuid") String componentUuid, @Param("analysisUuid") String analysisUuid, @Param("metricIds") List<Integer> metricIds);
+  List<PastMeasureDto> selectPastMeasuresOnSingleAnalysis(@Param("componentUuid") String componentUuid, @Param("analysisUuid") String analysisUuid,
+    @Param("metricIds") List<Integer> metricIds);
+
+  List<MeasureDto> selectPastMeasuresOnSeveralAnalyses(@Param("componentUuid") String componentUuid, @Param("analysisUuids") Collection<String> analysisUuid,
+    @Param("metricIds") Collection<Integer> metricIds);
 
   List<MeasureDto> selectProjectMeasuresOfDeveloper(@Param("developerId") long developerId, @Param("metricIds") Collection<Integer> metricIds);
 
index f19fed07105e8be3672efbaa92793cc28c498ea1..d0a4fbd84d0291727931d929275bcc450026658e 100644 (file)
@@ -20,7 +20,6 @@
 package org.sonar.db.metric;
 
 import java.util.function.Predicate;
-import javax.annotation.Nonnull;
 
 /**
  * Common functions on MetricDto
@@ -32,15 +31,6 @@ public class MetricDtoFunctions {
 
 
   public static Predicate<MetricDto> isOptimizedForBestValue() {
-    return IsMetricOptimizedForBestValue.INSTANCE;
-  }
-
-  private enum IsMetricOptimizedForBestValue implements Predicate<MetricDto> {
-    INSTANCE;
-
-    @Override
-    public boolean test(@Nonnull MetricDto input) {
-      return input.isOptimizedBestValue() && input.getBestValue() != null;
-    }
+    return m -> m != null && m.isOptimizedBestValue() && m.getBestValue() != null;
   }
 }
index 9b5900bba353f83fcc21ef9044ec962434ee04e3..c56d91b0cb5be24da7c97596ce110038159265be 100644 (file)
     <include refid="org.sonar.db.component.ComponentMapper.selectDescendantsFilters"/>
   </sql>
 
-  <select id="selectPastMeasures" parameterType="map" resultType="org.sonar.db.measure.PastMeasureDto">
+  <select id="selectPastMeasuresOnSingleAnalysis" parameterType="map" resultType="org.sonar.db.measure.PastMeasureDto">
     select pm.id as id, pm.metric_id as metricId, pm.person_id as personId, pm.value as value
     from project_measures pm
     inner join snapshots analysis on analysis.uuid = pm.analysis_uuid
       and pm.person_id is null
   </select>
 
+  <select id="selectPastMeasuresOnSeveralAnalyses" parameterType="map" resultType="Measure">
+    select <include refid="measureColumns"/>
+    from project_measures pm
+    inner join snapshots analysis on analysis.uuid = pm.analysis_uuid
+    where
+    pm.component_uuid = #{componentUuid}
+    and analysis.uuid in <foreach item="analysisUuid" collection="analysisUuids" open="(" separator="," close=")">#{analysisUuid}</foreach>
+    and pm.metric_id in <foreach item="metricId" collection="metricIds" open="(" separator="," close=")">#{metricId}</foreach>
+    and pm.person_id is null
+  </select>
+
   <select id="selectProjectMeasuresOfDeveloper" parameterType="map" resultType="Measure">
     SELECT
       <include refid="measureColumns"/>
index 4fb8fb3b8d324e41bac86c2de88fcf13a19fec58..0ac06eccd8f730233beeb943fd7f4f69e4c3d5b8 100644 (file)
@@ -35,6 +35,7 @@ import org.sonar.db.component.ComponentDto;
 import org.sonar.db.component.SnapshotTesting;
 import org.sonar.db.organization.OrganizationDto;
 
+import static com.google.common.collect.Lists.newArrayList;
 import static java.util.Arrays.asList;
 import static java.util.Collections.emptyList;
 import static java.util.Collections.singletonList;
@@ -449,6 +450,24 @@ public class MeasureDaoTest {
     verifyMeasures(file1, MeasureTreeQuery.builder().setStrategy(LEAVES), "M2", "M3");
   }
 
+  @Test
+  public void select_past_measures_with_several_analyses() {
+    ComponentDto project = db.components().insertProject();
+    insertAnalysis(LAST_ANALYSIS_UUID, project.uuid(), true);
+    insertAnalysis(OTHER_ANALYSIS_UUID, project.uuid(), false);
+    db.components().indexAllComponents();
+
+    // project
+    insertMeasure("PROJECT_M1", LAST_ANALYSIS_UUID, project.uuid(), NCLOC_METRIC_ID);
+    insertMeasure("PROJECT_M2", OTHER_ANALYSIS_UUID, project.uuid(), NCLOC_METRIC_ID);
+    db.commit();
+
+    // Children measures of project
+    List<MeasureDto> result = underTest.selectPastMeasures(db.getSession(), project.uuid(), newArrayList(LAST_ANALYSIS_UUID, OTHER_ANALYSIS_UUID), singletonList(NCLOC_METRIC_ID));
+
+    assertThat(result).hasSize(2).extracting(MeasureDto::getData).containsOnly("PROJECT_M1", "PROJECT_M2");
+  }
+
   private Optional<MeasureDto> selectSingle(MeasureQuery.Builder query) {
     return underTest.selectSingle(db.getSession(), query.build());
   }
index c6c39285761430c062243639400957d03d0801c2..333287250bb0706a4ab5488869190800c1f7e82d 100644 (file)
  */
 package org.sonarqube.ws.client.measure;
 
+import org.sonar.api.server.ws.WebService.Param;
 import org.sonarqube.ws.WsMeasures.ComponentTreeWsResponse;
 import org.sonarqube.ws.WsMeasures.ComponentWsResponse;
+import org.sonarqube.ws.WsMeasures.SearchHistoryResponse;
 import org.sonarqube.ws.client.BaseService;
 import org.sonarqube.ws.client.GetRequest;
 import org.sonarqube.ws.client.WsConnector;
 
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.ACTION_COMPONENT;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.ACTION_COMPONENT_TREE;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.ACTION_SEARCH_HISTORY;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.CONTROLLER_MEASURES;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_ADDITIONAL_FIELDS;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_BASE_COMPONENT_ID;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_BASE_COMPONENT_KEY;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_COMPONENT;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_COMPONENT_ID;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_COMPONENT_KEY;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_DEVELOPER_ID;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_DEVELOPER_KEY;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_FROM;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRICS;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRIC_KEYS;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRIC_SORT;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRIC_SORT_FILTER;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_QUALIFIERS;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_STRATEGY;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_TO;
 
 public class MeasuresService extends BaseService {
   public MeasuresService(WsConnector wsConnector) {
@@ -78,4 +85,16 @@ public class MeasuresService extends BaseService {
 
     return call(getRequest, ComponentWsResponse.parser());
   }
+
+  public SearchHistoryResponse searchHistory(SearchHistoryRequest request) {
+    GetRequest getRequest = new GetRequest(path(ACTION_SEARCH_HISTORY))
+      .setParam(PARAM_COMPONENT, request.getComponent())
+      .setParam(PARAM_METRICS, inlineMultipleParamValue(request.getMetrics()))
+      .setParam(PARAM_FROM, request.getFrom())
+      .setParam(PARAM_TO, request.getTo())
+      .setParam(Param.PAGE, request.getPage())
+      .setParam(Param.PAGE_SIZE, request.getPageSize());
+
+    return call(getRequest, SearchHistoryResponse.parser());
+  }
 }
index f83b5427f6593131d95966482783cb40834cb715..9625fa8f4910393121db6bec448a1804dd8d55a8 100644 (file)
@@ -28,14 +28,16 @@ public class MeasuresWsParameters {
 
   // actions
   public static final String ACTION_COMPONENT_TREE = "component_tree";
-
   public static final String ACTION_COMPONENT = "component";
+  public static final String ACTION_SEARCH_HISTORY = "search_history";
+
   // parameters
   public static final String PARAM_BASE_COMPONENT_ID = "baseComponentId";
-
   public static final String PARAM_BASE_COMPONENT_KEY = "baseComponentKey";
+  public static final String PARAM_COMPONENT = "component";
   public static final String PARAM_STRATEGY = "strategy";
   public static final String PARAM_QUALIFIERS = "qualifiers";
+  public static final String PARAM_METRICS = "metrics";
   public static final String PARAM_METRIC_KEYS = "metricKeys";
   public static final String PARAM_METRIC_SORT = "metricSort";
   public static final String PARAM_METRIC_PERIOD_SORT = "metricPeriodSort";
@@ -46,9 +48,12 @@ public class MeasuresWsParameters {
   public static final String PARAM_PROJECT_KEYS = "projectKeys";
   public static final String PARAM_DEVELOPER_ID = "developerId";
   public static final String PARAM_DEVELOPER_KEY = "developerKey";
-  public static final String ADDITIONAL_METRICS = "metrics";
+  public static final String PARAM_FROM = "from";
+  public static final String PARAM_TO = "to";
 
+  public static final String ADDITIONAL_METRICS = "metrics";
   public static final String ADDITIONAL_PERIODS = "periods";
+
   public static final Set<String> ADDITIONAL_FIELDS = ImmutableSortedSet.of(ADDITIONAL_METRICS, ADDITIONAL_PERIODS);
 
   private MeasuresWsParameters() {
diff --git a/sonar-ws/src/main/java/org/sonarqube/ws/client/measure/SearchHistoryRequest.java b/sonar-ws/src/main/java/org/sonarqube/ws/client/measure/SearchHistoryRequest.java
new file mode 100644 (file)
index 0000000..890229b
--- /dev/null
@@ -0,0 +1,134 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.sonarqube.ws.client.measure;
+
+import java.util.List;
+import javax.annotation.CheckForNull;
+
+import static java.lang.String.format;
+
+public class SearchHistoryRequest {
+  public static final int MAX_PAGE_SIZE = 1_000;
+  public static final int DEFAULT_PAGE_SIZE = 100;
+
+  private final String component;
+  private final List<String> metrics;
+  private final String from;
+  private final String to;
+  private final int page;
+  private final int pageSize;
+
+  public SearchHistoryRequest(Builder builder) {
+    this.component = builder.component;
+    this.metrics = builder.metrics;
+    this.from = builder.from;
+    this.to = builder.to;
+    this.page = builder.page;
+    this.pageSize = builder.pageSize;
+  }
+
+  public String getComponent() {
+    return component;
+  }
+
+  public List<String> getMetrics() {
+    return metrics;
+  }
+
+  @CheckForNull
+  public String getFrom() {
+    return from;
+  }
+
+  @CheckForNull
+  public String getTo() {
+    return to;
+  }
+
+  public int getPage() {
+    return page;
+  }
+
+  public int getPageSize() {
+    return pageSize;
+  }
+
+  public static Builder builder() {
+    return new Builder();
+  }
+
+  public static class Builder {
+    private String component;
+    private List<String> metrics;
+    private String from;
+    private String to;
+    private int page = 1;
+    private int pageSize = DEFAULT_PAGE_SIZE;
+
+    private Builder() {
+      // enforce build factory method
+    }
+
+    public Builder setComponent(String component) {
+      this.component = component;
+      return this;
+    }
+
+    public Builder setMetrics(List<String> metrics) {
+      this.metrics = metrics;
+      return this;
+    }
+
+    public Builder setFrom(String from) {
+      this.from = from;
+      return this;
+    }
+
+    public Builder setTo(String to) {
+      this.to = to;
+      return this;
+    }
+
+    public Builder setPage(int page) {
+      this.page = page;
+      return this;
+    }
+
+    public Builder setPageSize(int pageSize) {
+      this.pageSize = pageSize;
+      return this;
+    }
+
+    public SearchHistoryRequest build() {
+      checkArgument(component != null && !component.isEmpty(), "Component key is required");
+      checkArgument(metrics != null && !metrics.isEmpty(), "Metric keys are required");
+      checkArgument(pageSize <= MAX_PAGE_SIZE, "Page size (%d) must be lower than or equal to %d", pageSize, MAX_PAGE_SIZE);
+
+      return new SearchHistoryRequest(this);
+    }
+
+    private static void checkArgument(boolean condition, String message, Object... args) {
+      if (!condition) {
+        throw new IllegalArgumentException(format(message, args));
+      }
+    }
+  }
+}
index 3636436de74e4fbdd9122d7e0d314b0cb896dad7..4c64b51ba474851f9a909b099372e3588f83c88e 100644 (file)
@@ -47,6 +47,22 @@ message SearchWsResponse {
   repeated Measure measures = 1;
 }
 
+// WS api/measures/search_history
+message SearchHistoryResponse {
+  optional sonarqube.ws.commons.Paging paging = 1;
+  repeated HistoryMeasure measures = 2;
+
+  message HistoryMeasure {
+    optional string metric = 1;
+    repeated HistoryValue history = 2;
+  }
+
+  message HistoryValue {
+    optional string date = 1;
+    optional string value = 2;
+  }
+}
+
 message Component {
   optional string id = 1;
   optional string key = 2;
index d5087a971c1319a2da815bfbf83b52a7b2051a2e..3ffa87a42f5738eba2fe198adc16b290487f2ada 100644 (file)
@@ -23,6 +23,7 @@ import java.util.ArrayList;
 import java.util.List;
 import org.junit.Rule;
 import org.junit.Test;
+import org.sonarqube.ws.WsMeasures;
 import org.sonarqube.ws.WsMeasures.ComponentTreeWsResponse;
 import org.sonarqube.ws.client.GetRequest;
 import org.sonarqube.ws.client.ServiceTester;
@@ -34,18 +35,24 @@ import static org.mockito.Mockito.mock;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_ADDITIONAL_FIELDS;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_BASE_COMPONENT_ID;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_BASE_COMPONENT_KEY;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_COMPONENT;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_DEVELOPER_ID;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_DEVELOPER_KEY;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_FROM;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRICS;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRIC_KEYS;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRIC_SORT;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_METRIC_SORT_FILTER;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_QUALIFIERS;
 import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_STRATEGY;
+import static org.sonarqube.ws.client.measure.MeasuresWsParameters.PARAM_TO;
 
 public class MeasuresServiceTest {
   private static final String VALUE_BASE_COMPONENT_ID = "base-component-id";
   private static final String VALUE_BASE_COMPONENT_KEY = "base-component-key";
+  private static final String VALUE_COMPONENT = "component-key";
   private static final List<String> VALUE_METRIC_KEYS = newArrayList("ncloc", "complexity");
+  private static final List<String> VALUE_METRICS = newArrayList("ncloc", "complexity");
   private static final String VALUE_STRATEGY = "all";
   private static final List<String> VALUE_QUALIFIERS = newArrayList("FIL", "PRJ");
   private static final ArrayList<String> VALUE_ADDITIONAL_FIELDS = newArrayList("metrics");
@@ -54,10 +61,12 @@ public class MeasuresServiceTest {
   private static final String VALUE_METRIC_SORT = "ncloc";
   private static final String VALUE_METRIC_SORT_FILTER = "all";
   private static final int VALUE_PAGE = 42;
-  private static final int VALUE_PAGE_SIZE = 1984;
+  private static final int VALUE_PAGE_SIZE = 1000;
   private static final String VALUE_QUERY = "query-sq";
   private static final String VALUE_DEVELOPER_ID = "developer-id";
   private static final String VALUE_DEVELOPER_KEY = "developer-key";
+  private static final String VALUE_FROM = "2017-10-01";
+  private static final String VALUE_TO = "2017-11-01";
 
   @Rule
   public ServiceTester<MeasuresService> serviceTester = new ServiceTester<>(new MeasuresService(mock(WsConnector.class)));
@@ -105,4 +114,29 @@ public class MeasuresServiceTest {
       .hasParam(PARAM_METRIC_SORT_FILTER, VALUE_METRIC_SORT_FILTER)
       .andNoOtherParam();
   }
+
+  @Test
+  public void search_history() {
+    SearchHistoryRequest request = SearchHistoryRequest.builder()
+      .setComponent(VALUE_COMPONENT)
+      .setMetrics(VALUE_METRICS)
+      .setFrom(VALUE_FROM)
+      .setTo(VALUE_TO)
+      .setPage(VALUE_PAGE)
+      .setPageSize(VALUE_PAGE_SIZE)
+      .build();
+
+    underTest.searchHistory(request);
+    GetRequest getRequest = serviceTester.getGetRequest();
+
+    assertThat(serviceTester.getGetParser()).isSameAs(WsMeasures.SearchHistoryResponse.parser());
+    serviceTester.assertThat(getRequest)
+      .hasParam(PARAM_COMPONENT, VALUE_COMPONENT)
+      .hasParam(PARAM_METRICS, "ncloc,complexity")
+      .hasParam(PARAM_FROM, VALUE_FROM)
+      .hasParam(PARAM_TO, VALUE_TO)
+      .hasParam("p", VALUE_PAGE)
+      .hasParam("ps", VALUE_PAGE_SIZE)
+      .andNoOtherParam();
+  }
 }
diff --git a/sonar-ws/src/test/java/org/sonarqube/ws/client/measure/SearchHistoryRequestTest.java b/sonar-ws/src/test/java/org/sonarqube/ws/client/measure/SearchHistoryRequestTest.java
new file mode 100644 (file)
index 0000000..4248918
--- /dev/null
@@ -0,0 +1,103 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact 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.sonarqube.ws.client.measure;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+import static java.util.Collections.emptyList;
+import static java.util.Collections.singletonList;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.sonarqube.ws.client.measure.SearchHistoryRequest.DEFAULT_PAGE_SIZE;
+import static org.sonarqube.ws.client.measure.SearchHistoryRequest.MAX_PAGE_SIZE;
+
+public class SearchHistoryRequestTest {
+  @Rule
+  public ExpectedException expectedException = ExpectedException.none();
+
+  private SearchHistoryRequest.Builder underTest = SearchHistoryRequest.builder();
+
+  @Test
+  public void full_example() {
+    SearchHistoryRequest result = underTest
+      .setComponent("C1")
+      .setMetrics(singletonList("new_lines"))
+      .setFrom("2017-01-15")
+      .setTo("2017-01-20")
+      .setPage(23)
+      .setPageSize(42)
+      .build();
+
+    assertThat(result)
+      .extracting(SearchHistoryRequest::getComponent, SearchHistoryRequest::getMetrics, SearchHistoryRequest::getFrom, SearchHistoryRequest::getTo,
+        SearchHistoryRequest::getPage, SearchHistoryRequest::getPageSize)
+      .containsExactly("C1", singletonList("new_lines"), "2017-01-15", "2017-01-20", 23, 42);
+  }
+
+  @Test
+  public void default_values() {
+    SearchHistoryRequest result = underTest.setComponent("C1").setMetrics(singletonList("new_lines")).build();
+
+    assertThat(result.getPage()).isEqualTo(1);
+    assertThat(result.getPageSize()).isEqualTo(DEFAULT_PAGE_SIZE);
+  }
+
+  @Test
+  public void fail_if_no_component() {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Component key is required");
+
+    underTest.setMetrics(singletonList("new_lines")).build();
+  }
+
+  @Test
+  public void fail_if_empty_component() {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Component key is required");
+
+    underTest.setComponent("").setMetrics(singletonList("new_lines")).build();
+  }
+
+  @Test
+  public void fail_if_no_metric() {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Metric keys are required");
+
+    underTest.setComponent("C1").build();
+  }
+
+  @Test
+  public void fail_if_empty_metrics() {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Metric keys are required");
+
+    underTest.setComponent("C1").setMetrics(emptyList()).build();
+  }
+
+  @Test
+  public void fail_if_page_size_greater_than_max_authorized_size() {
+    expectedException.expect(IllegalArgumentException.class);
+    expectedException.expectMessage("Page size (1001) must be lower than or equal to 1000");
+
+    underTest.setComponent("C1").setMetrics(singletonList("violations")).setPageSize(MAX_PAGE_SIZE + 1).build();
+  }
+}