com.google.gerrit.server.index.change.ChangeField Maven / Gradle / Ivy
// Copyright (C) 2013 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.index.change;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSet.toImmutableSet;
import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_CHANGE_NUMBER;
import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.stream.Collectors.joining;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import com.google.auto.value.AutoValue;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Splitter;
import com.google.common.collect.HashBasedTable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableTable;
import com.google.common.collect.Iterables;
import com.google.common.collect.Lists;
import com.google.common.collect.Table;
import com.google.common.flogger.FluentLogger;
import com.google.common.io.Files;
import com.google.common.primitives.Ints;
import com.google.common.primitives.Longs;
import com.google.common.reflect.TypeToken;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Address;
import com.google.gerrit.entities.AttentionSetUpdate;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.ChangeMessage;
import com.google.gerrit.entities.LabelType;
import com.google.gerrit.entities.LegacySubmitRequirement;
import com.google.gerrit.entities.PatchSetApproval;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.entities.SubmitRecord;
import com.google.gerrit.entities.SubmitRequirementResult;
import com.google.gerrit.entities.converter.ChangeProtoConverter;
import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
import com.google.gerrit.entities.converter.PatchSetProtoConverter;
import com.google.gerrit.entities.converter.ProtoConverter;
import com.google.gerrit.index.IndexedField;
import com.google.gerrit.index.RefState;
import com.google.gerrit.index.SchemaFieldDefs;
import com.google.gerrit.index.SchemaUtil;
import com.google.gerrit.json.OutputFormat;
import com.google.gerrit.proto.Entities;
import com.google.gerrit.server.ReviewerByEmailSet;
import com.google.gerrit.server.ReviewerSet;
import com.google.gerrit.server.cache.proto.Cache;
import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.notedb.SubmitRequirementProtoConverter;
import com.google.gerrit.server.project.SubmitRuleOptions;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ChangeStatusPredicate;
import com.google.gerrit.server.query.change.MagicLabelValue;
import com.google.gson.Gson;
import com.google.protobuf.MessageLite;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import org.eclipse.jgit.lib.PersonIdent;
/**
 * Fields indexed on change documents.
 *
 * Each field corresponds to both a field name supported by {@link ChangeQueryBuilder} for
 * querying that field, and a method on {@link ChangeData} used for populating the corresponding
 * document fields in the secondary index.
 *
 * 
Field names are all lowercase alphanumeric plus underscore; index implementations may create
 * unambiguous derived field names containing other characters.
 *
 * 
Note that this class does not override {@link Object#equals(Object)}. It relies on instances
 * being singletons so that the default (i.e. reference) comparison works.
 */
public class ChangeField {
  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
  public static final int NO_ASSIGNEE = -1;
  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
  /**
   * To avoid the non-google dependency on org.apache.lucene.index.IndexWriter.MAX_TERM_LENGTH it is
   * redefined here.
   */
  public static final int MAX_TERM_LENGTH = (1 << 15) - 2;
  // TODO: Rename LEGACY_ID to NUMERIC_ID
  /** Legacy change ID. */
  public static final IndexedField NUMERIC_ID_STR_FIELD =
      IndexedField.stringBuilder("NumericIdStr")
          .stored()
          .required()
          // The numeric change id is integer in string form
          .size(10)
          .build(cd -> String.valueOf(cd.virtualId().get()));
  public static final IndexedField.SearchSpec NUMERIC_ID_STR_SPEC =
      NUMERIC_ID_STR_FIELD.exact("legacy_id_str");
  public static final IndexedField CHANGENUM_FIELD =
      IndexedField.integerBuilder("ChangeNumber")
          .stored()
          .required()
          .build(cd -> cd.getId().get());
  public static final IndexedField.SearchSpec CHANGENUM_SPEC =
      CHANGENUM_FIELD.integer(FIELD_CHANGE_NUMBER);
  /** Newer style Change-Id key. */
  public static final IndexedField CHANGE_ID_FIELD =
      IndexedField.stringBuilder("ChangeId")
          .required()
          // The new style key is in form Isha1
          .size(41)
          .build(changeGetter(c -> c.getKey().get()));
  public static final IndexedField.SearchSpec CHANGE_ID_SPEC =
      CHANGE_ID_FIELD.prefix(ChangeQueryBuilder.FIELD_CHANGE_ID);
  /** Change status string, in the same format as {@code status:}. */
  public static final IndexedField STATUS_FIELD =
      IndexedField.stringBuilder("Status")
          .required()
          .size(20)
          .build(changeGetter(c -> ChangeStatusPredicate.canonicalize(c.getStatus())));
  public static final IndexedField.SearchSpec STATUS_SPEC =
      STATUS_FIELD.exact(ChangeQueryBuilder.FIELD_STATUS);
  /** Project containing the change. */
  public static final IndexedField PROJECT_FIELD =
      IndexedField.stringBuilder("Project")
          .required()
          .stored()
          .size(200)
          .build(changeGetter(c -> c.getProject().get()));
  public static final IndexedField.SearchSpec PROJECT_SPEC =
      PROJECT_FIELD.exact(ChangeQueryBuilder.FIELD_PROJECT);
  /** Project containing the change, as a prefix field. */
  public static final IndexedField.SearchSpec PROJECTS_SPEC =
      PROJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PROJECTS);
  /** Reference (aka branch) the change will submit onto. */
  public static final IndexedField REF_FIELD =
      IndexedField.stringBuilder("Ref")
          .required()
          .size(300)
          .build(changeGetter(c -> c.getDest().branch()));
  public static final IndexedField.SearchSpec REF_SPEC =
      REF_FIELD.exact(ChangeQueryBuilder.FIELD_REF);
  /** Topic, a short annotation on the branch. */
  public static final IndexedField TOPIC_FIELD =
      IndexedField.stringBuilder("Topic").size(500).build(ChangeField::getTopic);
  public static final IndexedField.SearchSpec EXACT_TOPIC =
      TOPIC_FIELD.exact("topic4");
  /** Topic, a short annotation on the branch. */
  public static final IndexedField.SearchSpec FUZZY_TOPIC =
      TOPIC_FIELD.fullText("topic5");
  /** Topic, a short annotation on the branch. */
  public static final IndexedField.SearchSpec PREFIX_TOPIC =
      TOPIC_FIELD.prefix("topic6");
  /** {@link com.google.gerrit.entities.SubmissionId} assigned by MergeOp. */
  public static final IndexedField SUBMISSIONID_FIELD =
      IndexedField.stringBuilder("SubmissionId")
          .size(500)
          .build(changeGetter(Change::getSubmissionId));
  public static final IndexedField.SearchSpec SUBMISSIONID_SPEC =
      SUBMISSIONID_FIELD.exact(ChangeQueryBuilder.FIELD_SUBMISSIONID);
  /** Last update time since January 1, 1970. */
  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
  public static final IndexedField UPDATED_FIELD =
      IndexedField.timestampBuilder("LastUpdated")
          .stored()
          .build(changeGetter(change -> Timestamp.from(change.getLastUpdatedOn())));
  public static final IndexedField.SearchSpec UPDATED_SPEC =
      UPDATED_FIELD.timestamp("updated2");
  /** When this change was merged, time since January 1, 1970. */
  // TODO(issue-15518): Migrate type for timestamp index fields from Timestamp to Instant
  public static final IndexedField MERGED_ON_FIELD =
      IndexedField.timestampBuilder("MergedOn")
          .stored()
          .build(
              cd -> cd.getMergedOn().map(Timestamp::from).orElse(null),
              (cd, field) -> cd.setMergedOn(field != null ? field.toInstant() : null));
  public static final IndexedField.SearchSpec MERGED_ON_SPEC =
      MERGED_ON_FIELD.timestamp(ChangeQueryBuilder.FIELD_MERGED_ON);
  /** List of full file paths modified in the current patch set. */
  public static final IndexedField> PATH_FIELD =
      IndexedField.iterableStringBuilder("ModifiedFile")
          .build(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
  public static final IndexedField>.SearchSpec PATH_SPEC =
      PATH_FIELD
          // Named for backwards compatibility.
          .exact(ChangeQueryBuilder.FIELD_FILE);
  public static Set getFileParts(ChangeData cd) {
    List paths = cd.currentFilePaths();
    Splitter s = Splitter.on('/').omitEmptyStrings();
    Set r = new HashSet<>();
    for (String path : paths) {
      for (String part : s.split(path)) {
        r.add(part);
      }
    }
    return r;
  }
  /** Hashtags tied to a change */
  public static final IndexedField> HASHTAG_FIELD =
      IndexedField.iterableStringBuilder("Hashtag")
          .size(200)
          .build(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
  public static final IndexedField>.SearchSpec HASHTAG_SPEC =
      HASHTAG_FIELD.exact(ChangeQueryBuilder.FIELD_HASHTAG);
  /** Hashtags as fulltext field for in-string search. */
  public static final IndexedField>.SearchSpec FUZZY_HASHTAG =
      HASHTAG_FIELD.fullText("hashtag2");
  /** Hashtags as prefix field for in-string search. */
  public static final IndexedField>.SearchSpec PREFIX_HASHTAG =
      HASHTAG_FIELD.prefix("hashtag3");
  /** Hashtags with original case. */
  public static final IndexedField> HASHTAG_CASE_AWARE_FIELD =
      IndexedField.iterableByteArrayBuilder("HashtagCaseAware")
          .stored()
          .build(
              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
              (cd, field) ->
                  cd.setHashtags(
                      StreamSupport.stream(field.spliterator(), false)
                          .map(f -> new String(f, UTF_8))
                          .collect(toImmutableSet())));
  public static final IndexedField>.SearchSpec
      HASHTAG_CASE_AWARE_SPEC = HASHTAG_CASE_AWARE_FIELD.storedOnly("_hashtag");
  /** Components of each file path modified in the current patch set. */
  public static final IndexedField> FILE_PART_FIELD =
      IndexedField.iterableStringBuilder("FilePart").build(ChangeField::getFileParts);
  public static final IndexedField>.SearchSpec FILE_PART_SPEC =
      FILE_PART_FIELD.exact(ChangeQueryBuilder.FIELD_FILEPART);
  /** File extensions of each file modified in the current patch set. */
  public static final IndexedField> EXTENSION_FIELD =
      IndexedField.iterableStringBuilder("Extension")
          .size(100)
          .build(ChangeField::getExtensions);
  public static final IndexedField>.SearchSpec EXTENSION_SPEC =
      EXTENSION_FIELD.exact(ChangeQueryBuilder.FIELD_EXTENSION);
  public static Set getExtensions(ChangeData cd) {
    return extensions(cd).collect(toSet());
  }
  /**
   * File extensions of each file modified in the current patch set as a sorted list. The purpose of
   * this field is to allow matching changes that only touch files with certain file extensions.
   */
  public static final IndexedField ONLY_EXTENSIONS_FIELD =
      IndexedField.stringBuilder("OnlyExtensions")
          .build(ChangeField::getAllExtensionsAsList);
  public static final IndexedField.SearchSpec ONLY_EXTENSIONS_SPEC =
      ONLY_EXTENSIONS_FIELD.exact(ChangeQueryBuilder.FIELD_ONLY_EXTENSIONS);
  public static String getAllExtensionsAsList(ChangeData cd) {
    return extensions(cd).distinct().sorted().collect(joining(","));
  }
  /**
   * Returns a stream with all file extensions that are used by files in the given change. A file
   * extension is defined as the portion of the filename following the final `.`. Files with no `.`
   * in their name have no extension. For them an empty string is returned as part of the stream.
   *
   * If the change contains multiple files with the same extension the extension is returned
   * multiple times in the stream (once per file).
   */
  private static Stream extensions(ChangeData cd) {
    return cd.currentFilePaths().stream()
        // Use case-insensitive file extensions even though other file fields are case-sensitive.
        // If we want to find "all Java files", we want to match both .java and .JAVA, even if we
        // normally care about case sensitivity. (Whether we should change the existing file/path
        // predicates to be case insensitive is a separate question.)
        .map(f -> Files.getFileExtension(f).toLowerCase(Locale.US));
  }
  /** Footers from the commit message of the current patch set. */
  public static final IndexedField> FOOTER_FIELD =
      IndexedField.iterableStringBuilder("Footer").build(ChangeField::getFooters);
  public static final IndexedField>.SearchSpec FOOTER_SPEC =
      FOOTER_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER);
  public static Set getFooters(ChangeData cd) {
    return cd.commitFooters().stream()
        .map(f -> f.toString().toLowerCase(Locale.US))
        .collect(toSet());
  }
  /** Footers from the commit message of the current patch set. */
  public static final IndexedField> FOOTER_NAME_FIELD =
      IndexedField.iterableStringBuilder("FooterName")
          .build(ChangeField::getFootersNames);
  public static final IndexedField>.SearchSpec FOOTER_NAME =
      FOOTER_NAME_FIELD.exact(ChangeQueryBuilder.FIELD_FOOTER_NAME);
  public static Set getFootersNames(ChangeData cd) {
    return cd.commitFooters().stream().map(f -> f.getKey()).collect(toSet());
  }
  /** Folders that are touched by the current patch set. */
  public static final IndexedField> DIRECTORY_FIELD =
      IndexedField.iterableStringBuilder("DirField").build(ChangeField::getDirectories);
  public static final IndexedField>.SearchSpec DIRECTORY_SPEC =
      DIRECTORY_FIELD.exact(ChangeQueryBuilder.FIELD_DIRECTORY);
  public static Set getDirectories(ChangeData cd) {
    List paths = cd.currentFilePaths();
    Splitter s = Splitter.on('/').omitEmptyStrings();
    Set r = new HashSet<>();
    for (String path : paths) {
      StringBuilder directory = new StringBuilder();
      r.add(directory.toString());
      String nextPart = null;
      for (String part : s.split(path.toLowerCase(Locale.US))) {
        if (nextPart != null) {
          r.add(nextPart);
          if (directory.length() > 0) {
            directory.append("/");
          }
          directory.append(nextPart);
          String intermediateDir = directory.toString();
          int i = intermediateDir.indexOf('/');
          while (i >= 0) {
            r.add(intermediateDir);
            intermediateDir = intermediateDir.substring(i + 1);
            i = intermediateDir.indexOf('/');
          }
        }
        nextPart = part;
      }
    }
    return r;
  }
  /** Owner/creator of the change. */
  public static final IndexedField OWNER_FIELD =
      IndexedField.integerBuilder("Owner")
          .required()
          .build(changeGetter(c -> c.getOwner().get()));
  public static final IndexedField.SearchSpec OWNER_SPEC =
      OWNER_FIELD.integer(ChangeQueryBuilder.FIELD_OWNER);
  /** Uploader of the latest patch set. */
  public static final IndexedField UPLOADER_FIELD =
      IndexedField.integerBuilder("Uploader")
          .required()
          .build(cd -> cd.currentPatchSet().uploader().get());
  public static final IndexedField.SearchSpec UPLOADER_SPEC =
      UPLOADER_FIELD.integer(ChangeQueryBuilder.FIELD_UPLOADER);
  /** References the source change number that this change was cherry-picked from. */
  public static final IndexedField CHERRY_PICK_OF_CHANGE_FIELD =
      IndexedField.integerBuilder("CherryPickOfChange")
          .build(
              cd ->
                  cd.change().getCherryPickOf() != null
                      ? cd.change().getCherryPickOf().changeId().get()
                      : null);
  public static final IndexedField.SearchSpec CHERRY_PICK_OF_CHANGE =
      CHERRY_PICK_OF_CHANGE_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE);
  /** References the source change patch-set that this change was cherry-picked from. */
  public static final IndexedField CHERRY_PICK_OF_PATCHSET_FIELD =
      IndexedField.integerBuilder("CherryPickOfPatchset")
          .build(
              cd ->
                  cd.change().getCherryPickOf() != null
                      ? cd.change().getCherryPickOf().get()
                      : null);
  public static final IndexedField.SearchSpec CHERRY_PICK_OF_PATCHSET =
      CHERRY_PICK_OF_PATCHSET_FIELD.integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_PATCHSET);
  /** This class decouples the internal and API types from storage. */
  private static class StoredAttentionSetEntry {
    final long timestampMillis;
    final int userId;
    final String reason;
    final AttentionSetUpdate.Operation operation;
    StoredAttentionSetEntry(AttentionSetUpdate attentionSetUpdate) {
      timestampMillis = attentionSetUpdate.timestamp().toEpochMilli();
      userId = attentionSetUpdate.account().get();
      reason = attentionSetUpdate.reason();
      operation = attentionSetUpdate.operation();
    }
    AttentionSetUpdate toAttentionSetUpdate() {
      return AttentionSetUpdate.createFromRead(
          Instant.ofEpochMilli(timestampMillis), Account.id(userId), operation, reason);
    }
  }
  /**
   * Users included in the attention set of the change. This omits timestamp, reason and possible
   * future fields.
   *
   * @see #ATTENTION_SET_FULL_SPEC
   */
  public static final IndexedField> ATTENTION_SET_USERS_FIELD =
      IndexedField.iterableIntegerBuilder("AttentionSetUsers")
          .build(ChangeField::getAttentionSetUserIds);
  public static final IndexedField>.SearchSpec ATTENTION_SET_USERS =
      ATTENTION_SET_USERS_FIELD.integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS);
  /** Number of changes that contain attention set. */
  public static final IndexedField ATTENTION_SET_USERS_COUNT_FIELD =
      IndexedField.integerBuilder("AttentionSetUsersCount")
          .stored()
          .build(cd -> additionsOnly(cd.attentionSet()).size());
  public static final IndexedField.SearchSpec ATTENTION_SET_USERS_COUNT =
      ATTENTION_SET_USERS_COUNT_FIELD.integerRange(
          ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT);
  /**
   * The full attention set data including timestamp, reason and possible future fields.
   *
   * @see #ATTENTION_SET_USERS
   */
  public static final IndexedField> ATTENTION_SET_FULL_FIELD =
      IndexedField.iterableByteArrayBuilder("AttentionSetFull")
          .stored()
          .required()
          .build(
              ChangeField::storedAttentionSet,
              (cd, value) ->
                  parseAttentionSet(
                      StreamSupport.stream(value.spliterator(), false)
                          .map(v -> new String(v, UTF_8))
                          .collect(toImmutableSet()),
                      cd));
  public static final IndexedField>.SearchSpec
      ATTENTION_SET_FULL_SPEC =
          ATTENTION_SET_FULL_FIELD.storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL);
  /** The user assigned to the change. */
  // The getter always returns NO_ASSIGNEE, since assignee field is deprecated.
  @Deprecated
  public static final IndexedField ASSIGNEE_FIELD =
      IndexedField.integerBuilder("Assignee").build(changeGetter(c -> NO_ASSIGNEE));
  @Deprecated
  public static final IndexedField.SearchSpec ASSIGNEE_SPEC =
      ASSIGNEE_FIELD.integer(ChangeQueryBuilder.FIELD_ASSIGNEE);
  /** Reviewer(s) associated with the change. */
  public static final IndexedField> REVIEWER_FIELD =
      IndexedField.iterableStringBuilder("Reviewer")
          .stored()
          .build(
              cd -> getReviewerFieldValues(cd.reviewers()),
              (cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
  public static final IndexedField>.SearchSpec REVIEWER_SPEC =
      REVIEWER_FIELD.exact("reviewer2");
  /** Reviewer(s) associated with the change that do not have a gerrit account. */
  public static final IndexedField> REVIEWER_BY_EMAIL_FIELD =
      IndexedField.iterableStringBuilder("ReviewerByEmail")
          .stored()
          .build(
              cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
              (cd, field) ->
                  cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
  public static final IndexedField>.SearchSpec REVIEWER_BY_EMAIL =
      REVIEWER_BY_EMAIL_FIELD.exact("reviewer_by_email");
  /** Reviewer(s) modified during change's current WIP phase. */
  public static final IndexedField> PENDING_REVIEWER_FIELD =
      IndexedField.iterableStringBuilder("PendingReviewer")
          .stored()
          .build(
              cd -> getReviewerFieldValues(cd.pendingReviewers()),
              (cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
  public static final IndexedField>.SearchSpec PENDING_REVIEWER_SPEC =
      PENDING_REVIEWER_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER);
  /** Reviewer(s) by email modified during change's current WIP phase. */
  public static final IndexedField> PENDING_REVIEWER_BY_EMAIL_FIELD =
      IndexedField.iterableStringBuilder("PendingReviewerByEmail")
          .stored()
          .build(
              cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
              (cd, field) ->
                  cd.setPendingReviewersByEmail(
                      parseReviewerByEmailFieldValues(cd.getId(), field)));
  public static final IndexedField>.SearchSpec
      PENDING_REVIEWER_BY_EMAIL =
          PENDING_REVIEWER_BY_EMAIL_FIELD.exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL);
  /** References a change that this change reverts. */
  public static final IndexedField REVERT_OF_FIELD =
      IndexedField.integerBuilder("RevertOf")
          .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
  public static final IndexedField.SearchSpec REVERT_OF =
      REVERT_OF_FIELD.integer(ChangeQueryBuilder.FIELD_REVERTOF);
  public static final IndexedField IS_PURE_REVERT_FIELD =
      IndexedField.stringBuilder("IsPureRevert")
          .size(1)
          .build(cd -> Boolean.TRUE.equals(cd.isPureRevert()) ? "1" : "0");
  public static final IndexedField.SearchSpec IS_PURE_REVERT_SPEC =
      IS_PURE_REVERT_FIELD.fullText(ChangeQueryBuilder.FIELD_PURE_REVERT);
  /**
   * Determines if a change is submittable based on {@link
   * com.google.gerrit.entities.SubmitRequirement}s.
   */
  public static final IndexedField IS_SUBMITTABLE_FIELD =
      IndexedField.stringBuilder("IsSubmittable")
          .size(1)
          .build(
              cd ->
                  // All submit requirements should be fulfilled
                  cd.submitRequirementsIncludingLegacy().values().stream()
                          .allMatch(SubmitRequirementResult::fulfilled)
                      ? "1"
                      : "0");
  public static final IndexedField.SearchSpec IS_SUBMITTABLE_SPEC =
      IS_SUBMITTABLE_FIELD.exact(ChangeQueryBuilder.FIELD_IS_SUBMITTABLE);
  @VisibleForTesting
  static List getReviewerFieldValues(ReviewerSet reviewers) {
    List r = new ArrayList<>(reviewers.asTable().size() * 2);
    for (Table.Cell c : reviewers.asTable().cellSet()) {
      String v = getReviewerFieldValue(c.getRowKey(), c.getColumnKey());
      r.add(v);
      r.add(v + ',' + c.getValue().toEpochMilli());
    }
    return r;
  }
  public static String getReviewerFieldValue(ReviewerStateInternal state, Account.Id id) {
    return state.toString() + ',' + id;
  }
  @VisibleForTesting
  static List getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
    List r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
    for (Table.Cell c :
        reviewersByEmail.asTable().cellSet()) {
      String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
      r.add(v);
      if (c.getColumnKey().name() != null) {
        // Add another entry without the name to provide search functionality on the email
        Address emailOnly = Address.create(c.getColumnKey().email());
        r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
      }
      r.add(v + ',' + c.getValue().toEpochMilli());
    }
    return r;
  }
  public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) {
    return state.toString() + ',' + adr;
  }
  public static ReviewerSet parseReviewerFieldValues(Change.Id changeId, Iterable values) {
    ImmutableTable.Builder b = ImmutableTable.builder();
    for (String v : values) {
      int i = v.indexOf(',');
      if (i < 0) {
        logger.atWarning().log(
            "Invalid value for reviewer field from change %s: %s", changeId.get(), v);
        continue;
      }
      int i2 = v.lastIndexOf(',');
      if (i2 == i) {
        // Don't log a warning here.
        // For each reviewer we store 2 values in the reviewer field, one value with the format
        // "," and one value with the format
        // ",," (see #getReviewerFieldValues(ReviewerSet)).
        // For parsing we are only interested in the ",,"
        // value and the "," value is ignored here.
        continue;
      }
      Optional reviewerState = getReviewerState(v.substring(0, i));
      if (!reviewerState.isPresent()) {
        logger.atWarning().log(
            "Failed to parse reviewer state of reviewer field from change %s: %s",
            changeId.get(), v);
        continue;
      }
      Optional accountId = Account.Id.tryParse(v.substring(i + 1, i2));
      if (!accountId.isPresent()) {
        logger.atWarning().log(
            "Failed to parse account ID of reviewer field from change %s: %s", changeId.get(), v);
        continue;
      }
      Long l = Longs.tryParse(v.substring(i2 + 1));
      if (l == null) {
        logger.atWarning().log(
            "Failed to parse timestamp of reviewer field from change %s: %s", changeId.get(), v);
        continue;
      }
      Instant timestamp = Instant.ofEpochMilli(l);
      b.put(reviewerState.get(), accountId.get(), timestamp);
    }
    return ReviewerSet.fromTable(b.build());
  }
  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(
      Change.Id changeId, Iterable values) {
    ImmutableTable.Builder b = ImmutableTable.builder();
    for (String v : values) {
      int i = v.indexOf(',');
      if (i < 0) {
        logger.atWarning().log(
            "Invalid value for reviewer by email field from change %s: %s", changeId.get(), v);
        continue;
      }
      int i2 = v.lastIndexOf(',');
      if (i2 == i) {
        // Don't log a warning here.
        // For each reviewer we store 2 values in the reviewer field, one value with the format
        // "," and one value with the format
        // ",," (see
        // #getReviewerByEmailFieldValues(ReviewerByEmailSet)).
        // For parsing we are only interested in the ",," value
        // and the "," value is ignored here.
        continue;
      }
      Optional reviewerState = getReviewerState(v.substring(0, i));
      if (!reviewerState.isPresent()) {
        logger.atWarning().log(
            "Failed to parse reviewer state of reviewer by email field from change %s: %s",
            changeId.get(), v);
        continue;
      }
      Address address = Address.tryParse(v.substring(i + 1, i2));
      if (address == null) {
        logger.atWarning().log(
            "Failed to parse address of reviewer by email field from change %s: %s",
            changeId.get(), v);
        continue;
      }
      Long l = Longs.tryParse(v.substring(i2 + 1));
      if (l == null) {
        logger.atWarning().log(
            "Failed to parse timestamp of reviewer by email field from change %s: %s",
            changeId.get(), v);
        continue;
      }
      Instant timestamp = Instant.ofEpochMilli(l);
      b.put(reviewerState.get(), address, timestamp);
    }
    return ReviewerByEmailSet.fromTable(b.build());
  }
  private static Optional getReviewerState(String value) {
    try {
      return Optional.of(ReviewerStateInternal.valueOf(value));
    } catch (IllegalArgumentException | NullPointerException e) {
      return Optional.empty();
    }
  }
  private static ImmutableSet getAttentionSetUserIds(ChangeData changeData) {
    return additionsOnly(changeData.attentionSet()).stream()
        .map(update -> update.account().get())
        .collect(toImmutableSet());
  }
  private static ImmutableSet storedAttentionSet(ChangeData changeData) {
    return changeData.attentionSet().stream()
        .map(StoredAttentionSetEntry::new)
        .map(storedAttentionSetEntry -> GSON.toJson(storedAttentionSetEntry).getBytes(UTF_8))
        .collect(toImmutableSet());
  }
  /**
   * Deserializes the specified attention set entries from JSON and stores them in the specified
   * change.
   */
  public static void parseAttentionSet(
      Collection storedAttentionSetEntriesJson, ChangeData changeData) {
    ImmutableSet attentionSet =
        storedAttentionSetEntriesJson.stream()
            .map(
                entry -> GSON.fromJson(entry, StoredAttentionSetEntry.class).toAttentionSetUpdate())
            .collect(toImmutableSet());
    changeData.setAttentionSet(attentionSet);
  }
  /** Commit ID of any patch set on the change, using prefix match. */
  public static final IndexedField> COMMIT_FIELD =
      IndexedField.iterableStringBuilder("CommitId")
          .size(40)
          .required()
          .build(ChangeField::getRevisions);
  public static final IndexedField>.SearchSpec COMMIT_SPEC =
      COMMIT_FIELD.prefix(ChangeQueryBuilder.FIELD_COMMIT);
  /** Commit ID of any patch set on the change, using exact match. */
  public static final IndexedField>.SearchSpec EXACT_COMMIT_SPEC =
      COMMIT_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMIT);
  private static ImmutableSet getRevisions(ChangeData cd) {
    return cd.patchSets().stream().map(ps -> ps.commitId().name()).collect(toImmutableSet());
  }
  /** Tracking id extracted from a footer. */
  public static final IndexedField> TR_FIELD =
      IndexedField.iterableStringBuilder("TrackingFooter")
          .build(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
  public static final IndexedField>.SearchSpec TR_SPEC =
      TR_FIELD.exact(ChangeQueryBuilder.FIELD_TR);
  /** List of labels on the current patch set including change owner votes. */
  public static final IndexedField> LABEL_FIELD =
      IndexedField.iterableStringBuilder("Label").required().build(cd -> getLabels(cd));
  public static final IndexedField>.SearchSpec LABEL_SPEC =
      LABEL_FIELD.exact("label2");
  private static Set getLabels(ChangeData cd) {
    Set allApprovals = new HashSet<>();
    Set distinctApprovals = new HashSet<>();
    Table voteCounts = HashBasedTable.create();
    for (PatchSetApproval a : cd.currentApprovals()) {
      if (a.value() != 0 && !a.isLegacySubmit()) {
        increment(voteCounts, a.label(), a.value());
        Optional labelType = cd.getLabelTypes().byLabel(a.labelId());
        allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
        allApprovals.addAll(getMagicLabelFormats(a.label(), a.value(), labelType, a.accountId()));
        allApprovals.addAll(getLabelOwnerFormats(a, cd, labelType));
        allApprovals.addAll(getLabelNonUploaderFormats(a, cd, labelType));
        distinctApprovals.add(formatLabel(a.label(), a.value()));
        distinctApprovals.addAll(
            getMagicLabelFormats(a.label(), a.value(), labelType, /* accountId= */ null));
      }
    }
    allApprovals.addAll(distinctApprovals);
    allApprovals.addAll(getCountLabelFormats(voteCounts, cd));
    return allApprovals;
  }
  private static void increment(Table table, String k1, short k2) {
    if (!table.contains(k1, k2)) {
      table.put(k1, k2, 1);
    } else {
      int val = table.get(k1, k2);
      table.put(k1, k2, val + 1);
    }
  }
  private static List getCountLabelFormats(
      Table voteCounts, ChangeData cd) {
    List allFormats = new ArrayList<>();
    for (String label : voteCounts.rowMap().keySet()) {
      Optional labelType = cd.getLabelTypes().byLabel(label);
      Map row = voteCounts.row(label);
      for (short vote : row.keySet()) {
        int count = row.get(vote);
        allFormats.addAll(getCountLabelFormats(labelType, label, vote, count));
      }
    }
    return allFormats;
  }
  private static List getCountLabelFormats(
      Optional labelType, String label, short vote, int count) {
    List formats =
        getMagicLabelFormats(label, vote, labelType, /* accountId= */ null, /* count= */ count);
    formats.add(formatLabel(label, vote, count));
    return formats;
  }
  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
  private static List getMagicLabelFormats(
      String label, short labelVal, Optional labelType, @Nullable Account.Id accountId) {
    return getMagicLabelFormats(label, labelVal, labelType, accountId, /* count= */ null);
  }
  /** Get magic label formats corresponding to the {MIN, MAX, ANY} label votes. */
  private static List getMagicLabelFormats(
      String label,
      short labelVal,
      Optional labelType,
      @Nullable Account.Id accountId,
      @Nullable Integer count) {
    List labels = new ArrayList<>();
    if (labelType.isPresent()) {
      if (labelVal == labelType.get().getMaxPositive()) {
        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId, count));
      }
      if (labelVal == labelType.get().getMaxNegative()) {
        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId, count));
      }
    }
    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId, count));
    return labels;
  }
  private static List getLabelOwnerFormats(
      PatchSetApproval a, ChangeData cd, Optional labelType) {
    List allFormats = new ArrayList<>();
    if (cd.change().getOwner().equals(a.accountId())) {
      allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
      allFormats.addAll(
          getMagicLabelFormats(
              a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
    }
    return allFormats;
  }
  private static List getLabelNonUploaderFormats(
      PatchSetApproval a, ChangeData cd, Optional labelType) {
    List allFormats = new ArrayList<>();
    if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
      allFormats.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
      allFormats.addAll(
          getMagicLabelFormats(
              a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
    }
    return allFormats;
  }
  public static Set getAuthorParts(ChangeData cd) {
    return SchemaUtil.getPersonParts(cd.getAuthor());
  }
  public static ImmutableSet getAuthorNameAndEmail(ChangeData cd) {
    return getNameAndEmail(cd.getAuthor());
  }
  public static Set getCommitterParts(ChangeData cd) {
    return SchemaUtil.getPersonParts(cd.getCommitter());
  }
  public static ImmutableSet getCommitterNameAndEmail(ChangeData cd) {
    return getNameAndEmail(cd.getCommitter());
  }
  private static ImmutableSet getNameAndEmail(PersonIdent person) {
    if (person == null) {
      return ImmutableSet.of();
    }
    String name = person.getName().toLowerCase(Locale.US);
    String email = person.getEmailAddress().toLowerCase(Locale.US);
    StringBuilder nameEmailBuilder = new StringBuilder();
    PersonIdent.appendSanitized(nameEmailBuilder, name);
    nameEmailBuilder.append(" <");
    PersonIdent.appendSanitized(nameEmailBuilder, email);
    nameEmailBuilder.append('>');
    return ImmutableSet.of(name, email, nameEmailBuilder.toString());
  }
  /**
   * The exact email address, or any part of the author name or email address, in the current patch
   * set.
   */
  public static final IndexedField> AUTHOR_PARTS_FIELD =
      IndexedField.iterableStringBuilder("AuthorParts")
          .required()
          .description(
              "The exact email address, or any part of the author name or email address, in the"
                  + " current patch set.")
          .build(ChangeField::getAuthorParts);
  public static final IndexedField>.SearchSpec AUTHOR_PARTS_SPEC =
      AUTHOR_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_AUTHOR);
  /** The exact name, email address and NameEmail of the author. */
  public static final IndexedField> EXACT_AUTHOR_FIELD =
      IndexedField.iterableStringBuilder("ExactAuthor")
          .required()
          .description("The exact name, email address and NameEmail of the author.")
          .build(ChangeField::getAuthorNameAndEmail);
  public static final IndexedField>.SearchSpec EXACT_AUTHOR_SPEC =
      EXACT_AUTHOR_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR);
  /**
   * The exact email address, or any part of the committer name or email address, in the current
   * patch set.
   */
  public static final IndexedField> COMMITTER_PARTS_FIELD =
      IndexedField.iterableStringBuilder("CommitterParts")
          .description(
              "The exact email address, or any part of the committer name or email address, in the"
                  + " current patch set.")
          .required()
          .build(ChangeField::getCommitterParts);
  public static final IndexedField>.SearchSpec COMMITTER_PARTS_SPEC =
      COMMITTER_PARTS_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMITTER);
  /** The exact name, email address, and NameEmail of the committer. */
  public static final IndexedField> EXACT_COMMITTER_FIELD =
      IndexedField.iterableStringBuilder("ExactCommiter")
          .required()
          .description("The exact name, email address, and NameEmail of the committer.")
          .build(ChangeField::getCommitterNameAndEmail);
  public static final IndexedField>.SearchSpec EXACT_COMMITTER_SPEC =
      EXACT_COMMITTER_FIELD.exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER);
  /** Serialized change object, used for pre-populating results. */
  private static final TypeToken CHANGE_TYPE_TOKEN =
      new TypeToken<>() {
        private static final long serialVersionUID = 1L;
      };
  public static final IndexedField CHANGE_FIELD =
      IndexedField.builder("Change", CHANGE_TYPE_TOKEN)
          .stored()
          .required()
          .protoConverter(Optional.of(ChangeProtoConverter.INSTANCE))
          .build(
              changeGetter(change -> entityToProto(ChangeProtoConverter.INSTANCE, change)),
              (cd, value) ->
                  cd.setChange(decodeProtoToEntity(value, ChangeProtoConverter.INSTANCE)));
  public static final IndexedField.SearchSpec CHANGE_SPEC =
      CHANGE_FIELD.storedOnly("_change");
  /** Serialized approvals for the current patch set, used for pre-populating results. */
  private static final TypeToken> APPROVAL_TYPE_TOKEN =
      new TypeToken<>() {
        private static final long serialVersionUID = 1L;
      };
  public static final IndexedField> APPROVAL_FIELD =
      IndexedField.>builder(
              "Approval", APPROVAL_TYPE_TOKEN)
          .stored()
          .required()
          .protoConverter(Optional.of(PatchSetApprovalProtoConverter.INSTANCE))
          .build(
              cd ->
                  entitiesToProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
              (cd, field) ->
                  cd.setCurrentApprovals(
                      decodeProtosToEntities(field, PatchSetApprovalProtoConverter.INSTANCE)));
  public static final IndexedField>.SearchSpec
      APPROVAL_SPEC = APPROVAL_FIELD.storedOnly("_approval");
  public static String formatLabel(String label, int value) {
    return formatLabel(label, value, /* accountId= */ null, /* count= */ null);
  }
  public static String formatLabel(String label, int value, @Nullable Integer count) {
    return formatLabel(label, value, /* accountId= */ null, count);
  }
  public static String formatLabel(String label, int value, Account.Id accountId) {
    return formatLabel(label, value, accountId, /* count= */ null);
  }
  public static String formatLabel(
      String label, int value, @Nullable Account.Id accountId, @Nullable Integer count) {
    return label.toLowerCase(Locale.US)
        + (value >= 0 ? "+" : "")
        + value
        + (accountId != null ? "," + formatAccount(accountId) : "")
        + (count != null ? ",count=" + count : "");
  }
  public static String formatLabel(String label, String value, @Nullable Integer count) {
    return formatLabel(label, value, /* accountId= */ null, count);
  }
  public static String formatLabel(
      String label, String value, @Nullable Account.Id accountId, @Nullable Integer count) {
    return label.toLowerCase(Locale.US)
        + "="
        + value
        + (accountId != null ? "," + formatAccount(accountId) : "")
        + (count != null ? ",count=" + count : "");
  }
  private static String formatAccount(Account.Id accountId) {
    if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
      return ChangeQueryBuilder.ARG_ID_OWNER;
    } else if (ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID.equals(accountId)) {
      return ChangeQueryBuilder.ARG_ID_NON_UPLOADER;
    }
    return Integer.toString(accountId.get());
  }
  /** Commit message of the current patch set. */
  public static final IndexedField COMMIT_MESSAGE_FIELD =
      IndexedField.stringBuilder("CommitMessage")
          .required()
          .build(ChangeData::commitMessage);
  public static final IndexedField.SearchSpec COMMIT_MESSAGE =
      COMMIT_MESSAGE_FIELD.fullText(ChangeQueryBuilder.FIELD_MESSAGE);
  /** Commit message of the current patch set, used to exactly match the commit message */
  public static final IndexedField COMMIT_MESSAGE_EXACT_FIELD =
      IndexedField.stringBuilder("CommitMessageExact")
          .required()
          .description(
              "Same as CommitMessage, but truncated, since supporting such large tokens may be"
                  + " problematic for indexes.")
          .build(cd -> truncateStringValueToMaxTermLength(cd.commitMessage()));
  public static final IndexedField.SearchSpec COMMIT_MESSAGE_EXACT =
      COMMIT_MESSAGE_EXACT_FIELD.exact(ChangeQueryBuilder.FIELD_MESSAGE_EXACT);
  /** Subject of the current patch set (aka first line of the commit message). */
  public static final IndexedField SUBJECT_FIELD =
      IndexedField.stringBuilder("Subject")
          .required()
          .build(changeGetter(Change::getSubject));
  public static final IndexedField.SearchSpec SUBJECT_SPEC =
      SUBJECT_FIELD.fullText(ChangeQueryBuilder.FIELD_SUBJECT);
  public static final IndexedField.SearchSpec PREFIX_SUBJECT_SPEC =
      SUBJECT_FIELD.prefix(ChangeQueryBuilder.FIELD_PREFIX_SUBJECT);
  /** Summary or inline comment. */
  public static final IndexedField> COMMENT_FIELD =
      IndexedField.iterableStringBuilder("Comment")
          .build(
              cd ->
                  Stream.concat(
                          cd.publishedComments().stream().map(c -> c.message),
                          // Some endpoint allow passing user message in input, and we still want to
                          // search by that. Index on message template with placeholders for user
                          // data, so we don't
                          // persist user identifiable information data in index.
                          cd.messages().stream().map(ChangeMessage::getMessage))
                      .collect(toSet()));
  public static final IndexedField>.SearchSpec COMMENT_SPEC =
      COMMENT_FIELD.fullText(ChangeQueryBuilder.FIELD_COMMENT);
  /** Number of unresolved comment threads of the change, including robot comments. */
  public static final IndexedField UNRESOLVED_COMMENT_COUNT_FIELD =
      IndexedField.integerBuilder("UnresolvedCommentCount")
          .stored()
          .build(
              ChangeData::unresolvedCommentCount,
              (cd, field) -> cd.setUnresolvedCommentCount(field));
  public static final IndexedField.SearchSpec UNRESOLVED_COMMENT_COUNT_SPEC =
      UNRESOLVED_COMMENT_COUNT_FIELD.integerRange(
          ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT);
  /** Total number of published inline comments of the change, including robot comments. */
  public static final IndexedField TOTAL_COMMENT_COUNT_FIELD =
      IndexedField.integerBuilder("TotalCommentCount")
          .stored()
          .build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
  public static final IndexedField.SearchSpec TOTAL_COMMENT_COUNT_SPEC =
      TOTAL_COMMENT_COUNT_FIELD.integerRange("total_comments");
  /** Whether the change is mergeable. */
  public static final IndexedField MERGEABLE_FIELD =
      IndexedField.stringBuilder("Mergeable")
          .stored()
          .size(1)
          .build(
              cd -> {
                Boolean m = cd.isMergeable();
                if (m == null) {
                  return null;
                }
                return m ? "1" : "0";
              },
              (cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
  public static final IndexedField.SearchSpec MERGEABLE_SPEC =
      MERGEABLE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGEABLE);
  /** Whether the change is a merge commit. */
  public static final IndexedField MERGE_FIELD =
      IndexedField.stringBuilder("Merge")
          .stored()
          .size(1)
          .build(
              cd -> {
                Boolean m = cd.isMerge();
                if (m == null) {
                  return null;
                }
                return m ? "1" : "0";
              });
  public static final IndexedField.SearchSpec MERGE_SPEC =
      MERGE_FIELD.exact(ChangeQueryBuilder.FIELD_MERGE);
  /** Whether the change is a cherry pick of another change. */
  public static final IndexedField CHERRY_PICK_FIELD =
      IndexedField.stringBuilder("CherryPick")
          .stored()
          .size(1)
          .build(cd -> cd.change().getCherryPickOf() != null ? "1" : "0");
  public static final IndexedField.SearchSpec CHERRY_PICK_SPEC =
      CHERRY_PICK_FIELD.exact(ChangeQueryBuilder.FIELD_CHERRYPICK);
  /** The number of inserted lines in this change. */
  public static final IndexedField ADDED_LINES_FIELD =
      IndexedField.integerBuilder("AddedLines")
          .stored()
          .build(
              cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
              (cd, field) -> {
                if (field != null) {
                  cd.setLinesInserted(field);
                }
              });
  public static final IndexedField.SearchSpec ADDED_LINES_SPEC =
      ADDED_LINES_FIELD.integerRange(ChangeQueryBuilder.FIELD_ADDED);
  /** The number of deleted lines in this change. */
  public static final IndexedField DELETED_LINES_FIELD =
      IndexedField.