com.google.gerrit.server.query.change.OutputStreamQuery Maven / Gradle / Ivy
// Copyright (C) 2014 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.query.change;
import static com.google.common.base.Preconditions.checkState;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Lists;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.common.PluginDefinedInfo;
import com.google.gerrit.index.query.QueryParseException;
import com.google.gerrit.index.query.QueryResult;
import com.google.gerrit.server.DynamicOptions;
import com.google.gerrit.server.account.AccountAttributeLoader;
import com.google.gerrit.server.config.TrackingFooters;
import com.google.gerrit.server.data.ChangeAttribute;
import com.google.gerrit.server.data.PatchSetAttribute;
import com.google.gerrit.server.data.QueryStatsAttribute;
import com.google.gerrit.server.events.EventFactory;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.project.SubmitRuleEvaluator;
import com.google.gerrit.server.project.SubmitRuleOptions;
import com.google.gerrit.server.util.time.TimeUtil;
import com.google.gson.Gson;
import com.google.inject.Inject;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.lang.reflect.Field;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.eclipse.jgit.attributes.AttributesNodeProvider;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.util.io.DisabledOutputStream;
/**
 * Change query implementation that outputs to a stream in the style of an SSH command.
 *
 * Instances are one-time-use. Other singleton classes should inject a Provider rather than
 * holding on to a single instance.
 */
public class OutputStreamQuery {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
  private static final DateTimeFormatter dtf =
      DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss zzz")
          .withLocale(Locale.US)
          .withZone(ZoneId.systemDefault());
  public enum OutputFormat {
    TEXT,
    JSON
  }
  public static final Gson GSON = new Gson();
  private final GitRepositoryManager repoManager;
  private final ChangeQueryBuilder queryBuilder;
  private final ChangeQueryProcessor queryProcessor;
  private final EventFactory eventFactory;
  private final TrackingFooters trackingFooters;
  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
  private final AccountAttributeLoader.Factory accountAttributeLoaderFactory;
  private OutputFormat outputFormat = OutputFormat.TEXT;
  private boolean includePatchSets;
  private boolean includeCurrentPatchSet;
  private boolean includeApprovals;
  private boolean includeComments;
  private boolean includeFiles;
  private boolean includeCommitMessage;
  private boolean includeDependencies;
  private boolean includeSubmitRecords;
  private boolean includeAllReviewers;
  private OutputStream outputStream = DisabledOutputStream.INSTANCE;
  private PrintWriter out;
  private ImmutableListMultimap pluginInfosByChange =
      ImmutableListMultimap.of();
  @Inject
  OutputStreamQuery(
      GitRepositoryManager repoManager,
      ChangeQueryBuilder queryBuilder,
      ChangeQueryProcessor queryProcessor,
      EventFactory eventFactory,
      TrackingFooters trackingFooters,
      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
      AccountAttributeLoader.Factory accountAttributeLoaderFactory) {
    this.repoManager = repoManager;
    this.queryBuilder = queryBuilder;
    this.queryProcessor = queryProcessor;
    this.eventFactory = eventFactory;
    this.trackingFooters = trackingFooters;
    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
    this.accountAttributeLoaderFactory = accountAttributeLoaderFactory;
  }
  void setLimit(int n) {
    queryProcessor.setUserProvidedLimit(n, /* applyDefaultLimit */ false);
  }
  public void setNoLimit(boolean on) {
    queryProcessor.setNoLimit(on);
  }
  public void setStart(int n) {
    queryProcessor.setStart(n);
  }
  public void setIncludePatchSets(boolean on) {
    includePatchSets = on;
  }
  public boolean getIncludePatchSets() {
    return includePatchSets;
  }
  public void setIncludeCurrentPatchSet(boolean on) {
    includeCurrentPatchSet = on;
  }
  public boolean getIncludeCurrentPatchSet() {
    return includeCurrentPatchSet;
  }
  public void setIncludeApprovals(boolean on) {
    includeApprovals = on;
  }
  public void setIncludeComments(boolean on) {
    includeComments = on;
  }
  public void setIncludeFiles(boolean on) {
    includeFiles = on;
  }
  public boolean getIncludeFiles() {
    return includeFiles;
  }
  public void setIncludeDependencies(boolean on) {
    includeDependencies = on;
  }
  public boolean getIncludeDependencies() {
    return includeDependencies;
  }
  public void setIncludeCommitMessage(boolean on) {
    includeCommitMessage = on;
  }
  public void setIncludeSubmitRecords(boolean on) {
    includeSubmitRecords = on;
  }
  public void setIncludeAllReviewers(boolean on) {
    includeAllReviewers = on;
  }
  public void setOutput(OutputStream out, OutputFormat fmt) {
    this.outputStream = out;
    this.outputFormat = fmt;
  }
  public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
    queryProcessor.setDynamicBean(plugin, dynamicBean);
  }
  public void query(String queryString) throws IOException {
    out =
        new PrintWriter( //
            new BufferedWriter( //
                new OutputStreamWriter(outputStream, UTF_8)));
    try {
      if (queryProcessor.isDisabled()) {
        ErrorMessage m = new ErrorMessage();
        m.message = "query disabled";
        show(m);
        return;
      }
      try {
        final QueryStatsAttribute stats = new QueryStatsAttribute();
        stats.runTimeMilliseconds = TimeUtil.nowMs();
        Map repos = new HashMap<>();
        Map revWalks = new HashMap<>();
        Map attributesNodeProviders = new HashMap<>();
        QueryResult results = queryProcessor.query(queryBuilder.parse(queryString));
        pluginInfosByChange = queryProcessor.createPluginDefinedInfos(results.entities());
        try {
          AccountAttributeLoader accountLoader = accountAttributeLoaderFactory.create();
          List changeAttributes = new ArrayList<>();
          for (ChangeData d : results.entities()) {
            changeAttributes.add(
                buildChangeAttribute(d, repos, revWalks, accountLoader, attributesNodeProviders));
          }
          accountLoader.fill();
          changeAttributes.forEach(c -> show(c));
        } finally {
          closeAll(revWalks.values(), repos.values());
        }
        stats.rowCount = results.entities().size();
        stats.moreChanges = results.more();
        stats.runTimeMilliseconds = TimeUtil.nowMs() - stats.runTimeMilliseconds;
        show(stats);
      } catch (StorageException err) {
        logger.atSevere().withCause(err).log("Cannot execute query: %s", queryString);
        ErrorMessage m = new ErrorMessage();
        m.message = "cannot query database";
        show(m);
      } catch (QueryParseException e) {
        ErrorMessage m = new ErrorMessage();
        m.message = e.getMessage();
        show(m);
      }
    } finally {
      try {
        out.flush();
      } finally {
        out = null;
      }
    }
  }
  private ChangeAttribute buildChangeAttribute(
      ChangeData d,
      Map repos,
      Map revWalks,
      AccountAttributeLoader accountLoader,
      Map attributesNodeProviders)
      throws IOException {
    ChangeAttribute c = eventFactory.asChangeAttribute(d.change(), accountLoader);
    c.hashtags = Lists.newArrayList(d.hashtags());
    eventFactory.extend(c, d.change());
    if (!trackingFooters.isEmpty()) {
      eventFactory.addTrackingIds(c, d.trackingFooters());
    }
    if (includeAllReviewers) {
      eventFactory.addAllReviewers(c, d.notes(), accountLoader);
    }
    if (includeSubmitRecords) {
      SubmitRuleOptions options =
          SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
      eventFactory.addSubmitRecords(
          c, submitRuleEvaluatorFactory.create(options).evaluate(d), accountLoader);
    }
    if (includeCommitMessage) {
      eventFactory.addCommitMessage(c, d.commitMessage());
    }
    if (includePatchSets || includeCurrentPatchSet || includeDependencies) {
      Project.NameKey p = d.change().getProject();
      Repository repo;
      AttributesNodeProvider attributesNodeProvider;
      RevWalk rw = revWalks.get(p);
      // Cache and reuse repos, revWalks, and attributesNodeProviders.
      if (rw == null) {
        repo = repoManager.openRepository(p);
        checkState(repos.put(p, repo) == null);
        rw = new RevWalk(repo);
        revWalks.put(p, rw);
        attributesNodeProvider = repo.createAttributesNodeProvider();
        attributesNodeProviders.put(p, attributesNodeProvider);
      } else {
        repo = repos.get(p);
        attributesNodeProvider = attributesNodeProviders.get(p);
      }
      if (includePatchSets) {
        eventFactory.addPatchSets(
            rw,
            repo.getConfig(),
            attributesNodeProvider,
            c,
            includeApprovals ? d.conditionallyLoadApprovalsWithCopied().asMap() : null,
            includeFiles,
            d,
            accountLoader);
        if (includeComments) {
          for (PatchSetAttribute attribute : c.patchSets) {
            eventFactory.addPatchSetComments(attribute, d.publishedComments(), accountLoader);
          }
        }
      }
      if (includeCurrentPatchSet) {
        PatchSet current = d.currentPatchSet();
        if (current != null) {
          if (includePatchSets) {
            for (PatchSetAttribute attribute : c.patchSets) {
              if (attribute.number == current.number()) {
                c.currentPatchSet = attribute.shallowClone();
                // approvals will be populated later using different logic than --patch-sets uses
                c.currentPatchSet.approvals = null;
                break;
              }
            }
          } else {
            c.currentPatchSet =
                eventFactory.asPatchSetAttribute(
                    rw,
                    repo.getConfig(),
                    repo.createAttributesNodeProvider(),
                    d,
                    current,
                    accountLoader);
            if (includeFiles) {
              eventFactory.addPatchSetFileNames(c.currentPatchSet, d.change(), d.currentPatchSet());
            }
            if (includeComments) {
              eventFactory.addPatchSetComments(
                  c.currentPatchSet, d.publishedComments(), accountLoader);
            }
          }
          eventFactory.addApprovals(
              c.currentPatchSet, d.currentApprovals(), d.getLabelTypes(), accountLoader);
        }
      }
      if (includeDependencies) {
        eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
      }
    }
    if (includeComments) {
      eventFactory.addComments(c, d.messages(), accountLoader);
    }
    ImmutableList pluginInfos = pluginInfosByChange.get(d.getId());
    if (!pluginInfos.isEmpty()) {
      c.plugins = pluginInfos;
    }
    return c;
  }
  private static void closeAll(Iterable revWalks, Iterable repos) {
    if (repos != null) {
      for (Repository repo : repos) {
        repo.close();
      }
    }
    if (revWalks != null) {
      for (RevWalk revWalk : revWalks) {
        revWalk.close();
      }
    }
  }
  private void show(Object data) {
    switch (outputFormat) {
      default:
      case TEXT:
        if (data instanceof ChangeAttribute) {
          out.print("change ");
          out.print(((ChangeAttribute) data).id);
          out.print("\n");
          showText(data, 1);
        } else {
          showText(data, 0);
        }
        out.print('\n');
        break;
      case JSON:
        out.print(GSON.toJson(data));
        out.print('\n');
        break;
    }
  }
  private void showText(Object data, int depth) {
    for (Field f : fieldsOf(data.getClass())) {
      Object val;
      try {
        val = f.get(data);
      } catch (IllegalArgumentException err) {
        continue;
      } catch (IllegalAccessException err) {
        continue;
      }
      if (val == null) {
        continue;
      }
      showField(f.getName(), val, depth);
    }
  }
  private String indent(int spaces) {
    if (spaces == 0) {
      return "";
    }
    return String.format("%" + spaces + "s", " ");
  }
  private void showField(String field, Object value, int depth) {
    final int spacesDepthRatio = 2;
    String indent = indent(depth * spacesDepthRatio);
    out.print(indent);
    out.print(field);
    out.print(':');
    if (value instanceof String && ((String) value).contains("\n")) {
      out.print(' ');
      // Idention for multi-line text is
      // current depth indetion + length of field + length of ": "
      indent = indent(indent.length() + field.length() + spacesDepthRatio);
      out.print(((String) value).replace("\n", "\n" + indent).trim());
      out.print('\n');
    } else if (value instanceof Long && isDateField(field)) {
      out.print(' ');
      out.print(dtf.format(Instant.ofEpochSecond((Long) value)));
      out.print('\n');
    } else if (isPrimitive(value)) {
      out.print(' ');
      out.print(value);
      out.print('\n');
    } else if (value instanceof Collection) {
      out.print('\n');
      boolean firstElement = true;
      for (Object thing : ((Collection>) value)) {
        // The name of the collection was initially printed at the beginning
        // of this routine.  Beginning at the second sub-element, reprint
        // the collection name so humans can separate individual elements
        // with less strain and error.
        //
        if (firstElement) {
          firstElement = false;
        } else {
          out.print(indent);
          out.print(field);
          out.print(":\n");
        }
        if (isPrimitive(thing)) {
          out.print(' ');
          out.print(value);
          out.print('\n');
        } else {
          showText(thing, depth + 1);
        }
      }
    } else {
      out.print('\n');
      showText(value, depth + 1);
    }
  }
  private static boolean isPrimitive(Object value) {
    return value instanceof String //
        || value instanceof Number //
        || value instanceof Boolean //
        || value instanceof Enum;
  }
  private static boolean isDateField(String name) {
    return "lastUpdated".equals(name) //
        || "grantedOn".equals(name) //
        || "timestamp".equals(name) //
        || "createdOn".equals(name);
  }
  private List fieldsOf(Class> type) {
    List r = new ArrayList<>();
    if (type.getSuperclass() != null) {
      r.addAll(fieldsOf(type.getSuperclass()));
    }
    r.addAll(Arrays.asList(type.getDeclaredFields()));
    return r;
  }
  static class ErrorMessage {
    public final String type = "error";
    public String message;
  }
}