FieldDef: Implement generic backfilling logic

The change index has special behavior for recovering field values
from the index. This is used to serve dashboards and change queries
fast and without looking up data in NoteDb or the ChangeNotesCache.

Until now, each index implementation has implemented this backfilling
on its own for all fields. This change is generalizing the logic
to reduce boiler-plate.

Each index implementation now only has to implement an interface that
converts from its specific types to the types defined by FieldDef
(String, Integer, Long, byte[], Timestamp).

This commit adds backfilling logic for modified lines in ChangeData.

Change-Id: I301bc13c6ab5d8f061aeac6a0aaa09ebdf9e9640
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
index 44a377a..562464d 100644
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -90,7 +90,7 @@
   protected static final String SEARCH = "_search";
   protected static final String SETTINGS = "settings";
 
-  protected static byte[] decodeBase64(String base64String) {
+  static byte[] decodeBase64(String base64String) {
     return BaseEncoding.base64().decode(base64String);
   }
 
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
index 162654d..7d4e0c7 100644
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -14,55 +14,35 @@
 
 package com.google.gerrit.elasticsearch;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
 import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
 import com.google.gerrit.elasticsearch.bulk.BulkRequest;
 import com.google.gerrit.elasticsearch.bulk.IndexRequest;
 import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 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.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.DataSource;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.ReviewerByEmailSet;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonElement;
 import com.google.gson.JsonObject;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.sql.Timestamp;
-import java.time.Instant;
-import java.time.format.DateTimeFormatter;
-import java.util.Collections;
-import java.util.Optional;
 import java.util.Set;
 import org.apache.http.HttpStatus;
 import org.eclipse.jgit.lib.Config;
@@ -190,242 +170,12 @@
         changeDataFactory.create(
             parseProtoFrom(decodeBase64(c.getAsString()), ChangeProtoConverter.INSTANCE));
 
-    // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
-
-    // Patch sets.
-    cd.setPatchSets(
-        decodeProtos(source, ChangeField.PATCH_SET.getName(), PatchSetProtoConverter.INSTANCE));
-
-    // Approvals.
-    if (source.get(ChangeField.APPROVAL.getName()) != null) {
-      cd.setCurrentApprovals(
-          decodeProtos(
-              source, ChangeField.APPROVAL.getName(), PatchSetApprovalProtoConverter.INSTANCE));
-    } else if (fields.contains(ChangeField.APPROVAL.getName())) {
-      cd.setCurrentApprovals(Collections.emptyList());
-    }
-
-    // Added & Deleted.
-    JsonElement addedElement = source.get(ChangeField.ADDED.getName());
-    JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
-    if (addedElement != null && deletedElement != null) {
-      // Changed lines.
-      int added = addedElement.getAsInt();
-      int deleted = deletedElement.getAsInt();
-      cd.setChangedLines(added, deleted);
-    }
-
-    // Star.
-    JsonElement starredElement = source.get(ChangeField.STAR.getName());
-    if (starredElement != null) {
-      ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
-      JsonArray starBy = starredElement.getAsJsonArray();
-      if (starBy.size() > 0) {
-        for (int i = 0; i < starBy.size(); i++) {
-          String[] indexableFields = starBy.get(i).getAsString().split(":");
-          Optional<Account.Id> id = Account.Id.tryParse(indexableFields[0]);
-          if (id.isPresent()) {
-            stars.put(id.get(), indexableFields[1]);
-          }
-        }
+    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      if (fields.contains(field.getName()) && source.get(field.getName()) != null) {
+        field.setIfPossible(cd, new ElasticStoredValue(source.get(field.getName())));
       }
-      cd.setStars(stars);
-    }
-
-    // Mergeable.
-    JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
-    if (mergeableElement != null && !skipFields.contains(ChangeField.MERGEABLE.getName())) {
-      String mergeable = mergeableElement.getAsString();
-      if ("1".equals(mergeable)) {
-        cd.setMergeable(true);
-      } else if ("0".equals(mergeable)) {
-        cd.setMergeable(false);
-      }
-    }
-
-    // Reviewed-by.
-    if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
-      JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
-      if (reviewedBy.size() > 0) {
-        Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-        for (int i = 0; i < reviewedBy.size(); i++) {
-          int aId = reviewedBy.get(i).getAsInt();
-          if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
-            break;
-          }
-          accounts.add(Account.id(aId));
-        }
-        cd.setReviewedBy(accounts);
-      }
-    } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
-      cd.setReviewedBy(Collections.emptySet());
-    }
-
-    // Hashtag.
-    if (source.get(ChangeField.HASHTAG.getName()) != null) {
-      JsonArray hashtagArray = source.get(ChangeField.HASHTAG.getName()).getAsJsonArray();
-      if (hashtagArray.size() > 0) {
-        Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtagArray.size());
-        for (int i = 0; i < hashtagArray.size(); i++) {
-          hashtags.add(hashtagArray.get(i).getAsString());
-        }
-        cd.setHashtags(hashtags);
-      }
-    } else if (fields.contains(ChangeField.HASHTAG.getName())) {
-      cd.setHashtags(Collections.emptySet());
-    }
-
-    // Star.
-    if (source.get(ChangeField.STAR.getName()) != null) {
-      JsonArray starArray = source.get(ChangeField.STAR.getName()).getAsJsonArray();
-      if (starArray.size() > 0) {
-        ListMultimap<Account.Id, String> stars =
-            MultimapBuilder.hashKeys().arrayListValues().build();
-        for (int i = 0; i < starArray.size(); i++) {
-          StarredChangesUtil.StarField starField =
-              StarredChangesUtil.StarField.parse(starArray.get(i).getAsString());
-          stars.put(starField.accountId(), starField.label());
-        }
-        cd.setStars(stars);
-      }
-    } else if (fields.contains(ChangeField.STAR.getName())) {
-      cd.setStars(ImmutableListMultimap.of());
-    }
-
-    // Reviewer.
-    if (source.get(ChangeField.REVIEWER.getName()) != null) {
-      cd.setReviewers(
-          ChangeField.parseReviewerFieldValues(
-              cd.getId(),
-              FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.REVIEWER.getName())) {
-      cd.setReviewers(ReviewerSet.empty());
-    }
-
-    // Reviewer-by-email.
-    if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
-      cd.setReviewersByEmail(
-          ChangeField.parseReviewerByEmailFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
-      cd.setReviewersByEmail(ReviewerByEmailSet.empty());
-    }
-
-    // Pending-reviewer.
-    if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
-      cd.setPendingReviewers(
-          ChangeField.parseReviewerFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
-      cd.setPendingReviewers(ReviewerSet.empty());
-    }
-
-    // Pending-reviewer-by-email.
-    if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
-      cd.setPendingReviewersByEmail(
-          ChangeField.parseReviewerByEmailFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
-      cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
-    }
-
-    // Stored-submit-record-strict.
-    decodeSubmitRecords(
-        source,
-        ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
-        ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
-        cd);
-
-    // Stored-submit-record-lenient.
-    decodeSubmitRecords(
-        source,
-        ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
-        ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
-        cd);
-
-    // Ref-state.
-    if (fields.contains(ChangeField.REF_STATE.getName())) {
-      cd.setRefStates(RefState.parseStates(getByteArray(source, ChangeField.REF_STATE.getName())));
-    }
-
-    // Ref-state-pattern.
-    if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
-      cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
-    }
-
-    // Unresolved-comment-count.
-    decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
-
-    // Attention set.
-    if (fields.contains(ChangeField.ATTENTION_SET_FULL.getName())) {
-      ChangeField.parseAttentionSet(
-          FluentIterable.from(source.getAsJsonArray(ChangeField.ATTENTION_SET_FULL.getName()))
-              .transform(ElasticChangeIndex::decodeBase64JsonElement)
-              .toSet(),
-          cd);
-    }
-
-    if (fields.contains(ChangeField.MERGED_ON.getName())) {
-      decodeMergedOn(source, cd);
     }
 
     return cd;
   }
-
-  private Iterable<byte[]> getByteArray(JsonObject source, String name) {
-    JsonElement element = source.get(name);
-    return element != null
-        ? Iterables.transform(element.getAsJsonArray(), e -> decodeBase64(e.getAsString()))
-        : Collections.emptyList();
-  }
-
-  private void decodeSubmitRecords(
-      JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
-    JsonArray records = doc.getAsJsonArray(fieldName);
-    if (records == null) {
-      return;
-    }
-    ChangeField.parseSubmitRecords(
-        FluentIterable.from(records)
-            .transform(ElasticChangeIndex::decodeBase64JsonElement)
-            .toList(),
-        opts,
-        out);
-  }
-
-  private static String decodeBase64JsonElement(JsonElement input) {
-    return new String(decodeBase64(input.getAsString()), UTF_8);
-  }
-
-  private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
-    JsonElement count = doc.get(fieldName);
-    if (count == null) {
-      return;
-    }
-    out.setUnresolvedCommentCount(count.getAsInt());
-  }
-
-  private void decodeMergedOn(JsonObject doc, ChangeData out) {
-    JsonElement mergedOnField = doc.get(ChangeField.MERGED_ON.getName());
-
-    Timestamp mergedOn = null;
-    if (mergedOnField != null) {
-      // Parse from ElasticMapping.TIMESTAMP_FIELD_FORMAT.
-      // We currently use built-in ISO-based dateOptionalTime.
-      // https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-date-format.html#built-in-date-formats
-      DateTimeFormatter isoFormatter = DateTimeFormatter.ISO_INSTANT;
-      mergedOn = Timestamp.from(Instant.from(isoFormatter.parse(mergedOnField.getAsString())));
-    }
-    out.setMergedOn(mergedOn);
-  }
 }
diff --git a/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java b/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java
new file mode 100644
index 0000000..a02a715
--- /dev/null
+++ b/java/com/google/gerrit/elasticsearch/ElasticStoredValue.java
@@ -0,0 +1,86 @@
+// Copyright (C) 2021 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.elasticsearch;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.gerrit.index.StoredValue;
+import com.google.gson.JsonElement;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.format.DateTimeFormatter;
+import java.util.stream.StreamSupport;
+
+/** Bridge to recover fields from the elastic index. */
+public class ElasticStoredValue implements StoredValue {
+  private final JsonElement field;
+
+  ElasticStoredValue(JsonElement field) {
+    this.field = field;
+  }
+
+  @Override
+  public String asString() {
+    return field.getAsString();
+  }
+
+  @Override
+  public Iterable<String> asStrings() {
+    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
+        .map(f -> f.getAsString())
+        .collect(toImmutableList());
+  }
+
+  @Override
+  public Integer asInteger() {
+    return field.getAsInt();
+  }
+
+  @Override
+  public Iterable<Integer> asIntegers() {
+    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
+        .map(f -> f.getAsInt())
+        .collect(toImmutableList());
+  }
+
+  @Override
+  public Long asLong() {
+    return field.getAsLong();
+  }
+
+  @Override
+  public Iterable<Long> asLongs() {
+    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
+        .map(f -> f.getAsLong())
+        .collect(toImmutableList());
+  }
+
+  @Override
+  public Timestamp asTimestamp() {
+    return Timestamp.from(Instant.from(DateTimeFormatter.ISO_INSTANT.parse(field.getAsString())));
+  }
+
+  @Override
+  public byte[] asByteArray() {
+    return AbstractElasticIndex.decodeBase64(field.getAsString());
+  }
+
+  @Override
+  public Iterable<byte[]> asByteArrays() {
+    return StreamSupport.stream(field.getAsJsonArray().spliterator(), false)
+        .map(f -> AbstractElasticIndex.decodeBase64(f.getAsString()))
+        .collect(toImmutableList());
+  }
+}
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
index 63f6887..eb64c1d 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.CharMatcher;
@@ -22,6 +23,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.Optional;
 
 /**
  * Definition of a field stored in the secondary index.
@@ -65,6 +67,11 @@
     T get(I input) throws IOException;
   }
 
+  @FunctionalInterface
+  public interface Setter<I, T> {
+    void set(I object, T value);
+  }
+
   public static class Builder<T> {
     private final FieldType<T> type;
     private final String name;
@@ -81,11 +88,20 @@
     }
 
     public <I> FieldDef<I, T> build(Getter<I, T> getter) {
-      return new FieldDef<>(name, type, stored, false, getter);
+      return new FieldDef<>(name, type, stored, false, getter, null);
+    }
+
+    public <I> FieldDef<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
+      return new FieldDef<>(name, type, stored, false, getter, setter);
     }
 
     public <I> FieldDef<I, Iterable<T>> buildRepeatable(Getter<I, Iterable<T>> getter) {
-      return new FieldDef<>(name, type, stored, true, getter);
+      return new FieldDef<>(name, type, stored, true, getter, null);
+    }
+
+    public <I> FieldDef<I, Iterable<T>> buildRepeatable(
+        Getter<I, Iterable<T>> getter, Setter<I, Iterable<T>> setter) {
+      return new FieldDef<>(name, type, stored, true, getter, setter);
     }
   }
 
@@ -96,9 +112,15 @@
 
   private final boolean repeatable;
   private final Getter<I, T> getter;
+  private final Optional<Setter<I, T>> setter;
 
   private FieldDef(
-      String name, FieldType<?> type, boolean stored, boolean repeatable, Getter<I, T> getter) {
+      String name,
+      FieldType<?> type,
+      boolean stored,
+      boolean repeatable,
+      Getter<I, T> getter,
+      @Nullable Setter<I, T> setter) {
     checkArgument(
         !(repeatable && type == FieldType.INTEGER_RANGE),
         "Range queries against repeated fields are unsupported");
@@ -107,6 +129,7 @@
     this.stored = stored;
     this.repeatable = repeatable;
     this.getter = requireNonNull(getter);
+    this.setter = Optional.ofNullable(setter);
   }
 
   private static String checkName(String name) {
@@ -145,6 +168,41 @@
     }
   }
 
+  /**
+   * Set the field contents back to an object. Used to reconstruct fields from indexed values. No-op
+   * if the field can't be reconstructed.
+   *
+   * @param object input object.
+   * @param doc indexed document
+   * @return {@code true} if the field was set, {@code false} otherwise
+   */
+  @SuppressWarnings("unchecked")
+  public boolean setIfPossible(I object, StoredValue doc) {
+    if (!setter.isPresent()) {
+      return false;
+    }
+
+    if (FieldType.STRING_TYPES.stream().anyMatch(t -> t.getName().equals(getType().getName()))) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asStrings() : doc.asString()));
+      return true;
+    } else if (FieldType.INTEGER_TYPES.stream()
+        .anyMatch(t -> t.getName().equals(getType().getName()))) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asIntegers() : doc.asInteger()));
+      return true;
+    } else if (FieldType.LONG.getName().equals(getType().getName())) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asLongs() : doc.asLong()));
+      return true;
+    } else if (FieldType.STORED_ONLY.getName().equals(getType().getName())) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asByteArrays() : doc.asByteArray()));
+      return true;
+    } else if (FieldType.TIMESTAMP.getName().equals(getType().getName())) {
+      checkState(!isRepeatable(), "can't repeat timestamp values");
+      setter.get().set(object, (T) doc.asTimestamp());
+      return true;
+    }
+    return false;
+  }
+
   /** @return whether the field is repeatable. */
   public boolean isRepeatable() {
     return repeatable;
diff --git a/java/com/google/gerrit/index/FieldType.java b/java/com/google/gerrit/index/FieldType.java
index 0db0284..c4c55f23 100644
--- a/java/com/google/gerrit/index/FieldType.java
+++ b/java/com/google/gerrit/index/FieldType.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.index;
 
+import com.google.common.collect.ImmutableList;
 import java.sql.Timestamp;
 
 /** Document field types supported by the secondary index system. */
@@ -42,6 +43,14 @@
   /** A field that is only stored as raw bytes and cannot be queried. */
   public static final FieldType<byte[]> STORED_ONLY = new FieldType<>("STORED_ONLY");
 
+  /** List of all types that are stored as {@link String} in the index. */
+  public static final ImmutableList<FieldType<String>> STRING_TYPES =
+      ImmutableList.of(EXACT, PREFIX, FULL_TEXT);
+
+  /** List of all types that are stored as {@link Integer} in the index. */
+  public static final ImmutableList<FieldType<Integer>> INTEGER_TYPES =
+      ImmutableList.of(INTEGER_RANGE, INTEGER);
+
   private final String name;
 
   private FieldType(String name) {
diff --git a/java/com/google/gerrit/index/StoredValue.java b/java/com/google/gerrit/index/StoredValue.java
new file mode 100644
index 0000000..fe790c5
--- /dev/null
+++ b/java/com/google/gerrit/index/StoredValue.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2021 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.index;
+
+import java.sql.Timestamp;
+
+/**
+ * Representation of a field stored on the index. Used to load field values from different index
+ * backends.
+ */
+public interface StoredValue {
+  /** Returns the {@link String} value of the field. */
+  String asString();
+
+  /** Returns the {@link String} values of the field. */
+  Iterable<String> asStrings();
+
+  /** Returns the {@link Integer} value of the field. */
+  Integer asInteger();
+
+  /** Returns the {@link Integer} values of the field. */
+  Iterable<Integer> asIntegers();
+
+  /** Returns the {@link Long} value of the field. */
+  Long asLong();
+
+  /** Returns the {@link Long} values of the field. */
+  Iterable<Long> asLongs();
+
+  /** Returns the {@link Timestamp} value of the field. */
+  Timestamp asTimestamp();
+
+  /** Returns the {@code byte[]} value of the field. */
+  byte[] asByteArray();
+
+  /** Returns the {@code byte[]} values of the field. */
+  Iterable<byte[]> asByteArrays();
+}
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index c3d4440..ac616ca 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
@@ -24,11 +23,8 @@
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Throwables;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
@@ -38,25 +34,19 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 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.exceptions.StorageException;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.FieldBundle;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -65,7 +55,6 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.inject.Inject;
@@ -73,16 +62,13 @@
 import com.google.protobuf.MessageLite;
 import java.io.IOException;
 import java.nio.file.Path;
-import java.sql.Timestamp;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
-import java.util.function.Consumer;
 import java.util.function.Function;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexWriter;
@@ -119,34 +105,10 @@
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
-  private static final String ADDED_FIELD = ChangeField.ADDED.getName();
-  private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
-  private static final String DELETED_FIELD = ChangeField.DELETED.getName();
-  private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
-  private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
-  private static final String PENDING_REVIEWER_FIELD = ChangeField.PENDING_REVIEWER.getName();
-  private static final String PENDING_REVIEWER_BY_EMAIL_FIELD =
-      ChangeField.PENDING_REVIEWER_BY_EMAIL.getName();
-  private static final String REF_STATE_FIELD = ChangeField.REF_STATE.getName();
-  private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
-  private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
-  private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
-  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
-  private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
-  private static final String STAR_FIELD = ChangeField.STAR.getName();
-  private static final String SUBMIT_RECORD_LENIENT_FIELD =
-      ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
-  private static final String SUBMIT_RECORD_STRICT_FIELD =
-      ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
-  private static final String TOTAL_COMMENT_COUNT_FIELD = ChangeField.TOTAL_COMMENT_COUNT.getName();
-  private static final String UNRESOLVED_COMMENT_COUNT_FIELD =
-      ChangeField.UNRESOLVED_COMMENT_COUNT.getName();
-  private static final String ATTENTION_SET_FULL_FIELD = ChangeField.ATTENTION_SET_FULL.getName();
-  private static final String MERGED_ON_FIELD = ChangeField.MERGED_ON.getName();
 
   @FunctionalInterface
-  static interface IdTerm {
+  interface IdTerm {
     Term get(String name, int id);
   }
 
@@ -159,7 +121,7 @@
   }
 
   @FunctionalInterface
-  static interface ChangeIdExtractor {
+  interface ChangeIdExtractor {
     Change.Id extract(IndexableField f);
   }
 
@@ -520,231 +482,14 @@
       cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
     }
 
-    // Any decoding that is done here must also be done in {@link ElasticChangeIndex}.
-
-    if (fields.contains(PATCH_SET_FIELD)) {
-      decodePatchSets(doc, cd);
+    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      if (fields.contains(field.getName()) && doc.get(field.getName()) != null) {
+        field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
+      }
     }
-    if (fields.contains(APPROVAL_FIELD)) {
-      decodeApprovals(doc, cd);
-    }
-    if (fields.contains(ADDED_FIELD) && fields.contains(DELETED_FIELD)) {
-      decodeChangedLines(doc, cd);
-    }
-    if (fields.contains(MERGEABLE_FIELD)) {
-      decodeMergeable(doc, cd);
-    }
-    if (fields.contains(REVIEWEDBY_FIELD)) {
-      decodeReviewedBy(doc, cd);
-    }
-    if (fields.contains(HASHTAG_FIELD)) {
-      decodeHashtags(doc, cd);
-    }
-    if (fields.contains(STAR_FIELD)) {
-      decodeStar(doc, cd);
-    }
-    if (fields.contains(REVIEWER_FIELD)) {
-      decodeReviewers(doc, cd);
-    }
-    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
-      decodeReviewersByEmail(doc, cd);
-    }
-    if (fields.contains(PENDING_REVIEWER_FIELD)) {
-      decodePendingReviewers(doc, cd);
-    }
-    if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) {
-      decodePendingReviewersByEmail(doc, cd);
-    }
-    if (fields.contains(ATTENTION_SET_FULL_FIELD)) {
-      decodeAttentionSet(doc, cd);
-    }
-    decodeSubmitRecords(
-        doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
-    decodeSubmitRecords(
-        doc, SUBMIT_RECORD_LENIENT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
-    if (fields.contains(REF_STATE_FIELD)) {
-      decodeRefStates(doc, cd);
-    }
-    if (fields.contains(REF_STATE_PATTERN_FIELD)) {
-      decodeRefStatePatterns(doc, cd);
-    }
-    if (fields.contains(MERGED_ON_FIELD)) {
-      decodeMergedOn(doc, cd);
-    }
-
-    decodeUnresolvedCommentCount(doc, cd);
-    decodeTotalCommentCount(doc, cd);
     return cd;
   }
 
-  private void decodePatchSets(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PatchSetProtoConverter.INSTANCE);
-    if (!patchSets.isEmpty()) {
-      // Will be an empty list for schemas prior to when this field was stored;
-      // this cannot be valid since a change needs at least one patch set.
-      cd.setPatchSets(patchSets);
-    }
-  }
-
-  private void decodeApprovals(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setCurrentApprovals(
-        decodeProtos(doc, APPROVAL_FIELD, PatchSetApprovalProtoConverter.INSTANCE));
-  }
-
-  private void decodeChangedLines(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField added = Iterables.getFirst(doc.get(ADDED_FIELD), null);
-    IndexableField deleted = Iterables.getFirst(doc.get(DELETED_FIELD), null);
-    if (added != null && deleted != null) {
-      cd.setChangedLines(added.numericValue().intValue(), deleted.numericValue().intValue());
-    } else {
-      // No ChangedLines stored, likely due to failure during reindexing, for
-      // example due to LargeObjectException. But we know the field was
-      // requested, so update ChangeData to prevent callers from trying to
-      // lazily load it, as that would probably also fail.
-      cd.setNoChangedLines();
-    }
-  }
-
-  private void decodeMergeable(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
-    if (f != null && !skipFields.contains(MERGEABLE_FIELD)) {
-      String mergeable = f.stringValue();
-      if ("1".equals(mergeable)) {
-        cd.setMergeable(true);
-      } else if ("0".equals(mergeable)) {
-        cd.setMergeable(false);
-      }
-    }
-  }
-
-  private void decodeReviewedBy(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> reviewedBy = doc.get(REVIEWEDBY_FIELD);
-    if (!reviewedBy.isEmpty()) {
-      Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-      for (IndexableField r : reviewedBy) {
-        int id = r.numericValue().intValue();
-        if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
-          break;
-        }
-        accounts.add(Account.id(id));
-      }
-      cd.setReviewedBy(accounts);
-    }
-  }
-
-  private void decodeHashtags(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> hashtag = doc.get(HASHTAG_FIELD);
-    Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtag.size());
-    for (IndexableField r : hashtag) {
-      hashtags.add(r.binaryValue().utf8ToString());
-    }
-    cd.setHashtags(hashtags);
-  }
-
-  private void decodeStar(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> star = doc.get(STAR_FIELD);
-    ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
-    for (IndexableField r : star) {
-      StarredChangesUtil.StarField starField = StarredChangesUtil.StarField.parse(r.stringValue());
-      if (starField != null) {
-        stars.put(starField.accountId(), starField.label());
-      }
-    }
-    cd.setStars(stars);
-  }
-
-  private void decodeReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setReviewers(
-        ChangeField.parseReviewerFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
-  }
-
-  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setReviewersByEmail(
-        ChangeField.parseReviewerByEmailFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodePendingReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setPendingReviewers(
-        ChangeField.parseReviewerFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(PENDING_REVIEWER_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodePendingReviewersByEmail(
-      ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setPendingReviewersByEmail(
-        ChangeField.parseReviewerByEmailFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(PENDING_REVIEWER_BY_EMAIL_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodeAttentionSet(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    ChangeField.parseAttentionSet(
-        doc.get(ATTENTION_SET_FULL_FIELD).stream()
-            .map(field -> field.binaryValue().utf8ToString())
-            .collect(toImmutableSet()),
-        cd);
-  }
-
-  private void decodeSubmitRecords(
-      ListMultimap<String, IndexableField> doc,
-      String field,
-      SubmitRuleOptions opts,
-      ChangeData cd) {
-    ChangeField.parseSubmitRecords(
-        Collections2.transform(doc.get(field), f -> f.binaryValue().utf8ToString()), opts, cd);
-  }
-
-  private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStates(RefState.parseStates(copyAsBytes(doc.get(REF_STATE_FIELD))));
-  }
-
-  private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStatePatterns(copyAsBytes(doc.get(REF_STATE_PATTERN_FIELD)));
-  }
-
-  private void decodeUnresolvedCommentCount(
-      ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    decodeIntField(doc, UNRESOLVED_COMMENT_COUNT_FIELD, cd::setUnresolvedCommentCount);
-  }
-
-  private void decodeTotalCommentCount(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    decodeIntField(doc, TOTAL_COMMENT_COUNT_FIELD, cd::setTotalCommentCount);
-  }
-
-  private static void decodeIntField(
-      ListMultimap<String, IndexableField> doc, String fieldName, Consumer<Integer> consumer) {
-    IndexableField f = Iterables.getFirst(doc.get(fieldName), null);
-    if (f != null && f.numericValue() != null) {
-      consumer.accept(f.numericValue().intValue());
-    }
-  }
-
-  private void decodeMergedOn(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField mergedOnField =
-        Iterables.getFirst(doc.get(MERGED_ON_FIELD), /* defaultValue= */ null);
-    Timestamp mergedOn = null;
-    if (mergedOnField != null && mergedOnField.numericValue() != null) {
-      mergedOn = new Timestamp(mergedOnField.numericValue().longValue());
-    }
-    cd.setMergedOn(mergedOn);
-  }
-
-  private static <T> List<T> decodeProtos(
-      ListMultimap<String, IndexableField> doc, String fieldName, ProtoConverter<?, T> converter) {
-    return doc.get(fieldName).stream()
-        .map(IndexableField::binaryValue)
-        .map(bytesRef -> parseProtoFrom(bytesRef, converter))
-        .collect(toImmutableList());
-  }
-
   private static <P extends MessageLite, T> T parseProtoFrom(
       BytesRef bytesRef, ProtoConverter<P, T> converter) {
     P message =
@@ -752,16 +497,4 @@
             converter.getParser(), bytesRef.bytes, bytesRef.offset, bytesRef.length);
     return converter.fromProto(message);
   }
-
-  private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
-    return fields.stream()
-        .map(
-            f -> {
-              BytesRef ref = f.binaryValue();
-              byte[] b = new byte[ref.length];
-              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
-              return b;
-            })
-        .collect(toList());
-  }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
new file mode 100644
index 0000000..efe489b
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -0,0 +1,95 @@
+// Copyright (C) 2021 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.lucene;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.StoredValue;
+import java.sql.Timestamp;
+import java.util.List;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.util.BytesRef;
+
+/** Bridge to recover fields from the lucene index. */
+public class LuceneStoredValue implements StoredValue {
+  /**
+   * Lucene represents repeated fields as a list of {@link IndexableField}, so we hold onto a list
+   * here to cover both repeated and non-repeated fields.
+   */
+  private final List<IndexableField> field;
+
+  LuceneStoredValue(List<IndexableField> field) {
+    this.field = field;
+  }
+
+  @Override
+  public String asString() {
+    return Iterables.getFirst(asStrings(), null);
+  }
+
+  @Override
+  public Iterable<String> asStrings() {
+    return field.stream().map(f -> f.stringValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Integer asInteger() {
+    return Iterables.getFirst(asIntegers(), null);
+  }
+
+  @Override
+  public Iterable<Integer> asIntegers() {
+    return field.stream().map(f -> f.numericValue().intValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Long asLong() {
+    return Iterables.getFirst(asLongs(), null);
+  }
+
+  @Override
+  public Iterable<Long> asLongs() {
+    return field.stream().map(f -> f.numericValue().longValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Timestamp asTimestamp() {
+    return asLong() == null ? null : new Timestamp(asLong());
+  }
+
+  @Override
+  public byte[] asByteArray() {
+    return Iterables.getFirst(asByteArrays(), null);
+  }
+
+  @Override
+  public Iterable<byte[]> asByteArrays() {
+    return copyAsBytes(field);
+  }
+
+  private static List<byte[]> copyAsBytes(List<IndexableField> fields) {
+    return fields.stream()
+        .map(
+            f -> {
+              BytesRef ref = f.binaryValue();
+              byte[] b = new byte[ref.length];
+              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
+              return b;
+            })
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index 7e84f1d..7131d44 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
@@ -57,6 +58,7 @@
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
 import com.google.gerrit.entities.converter.ProtoConverter;
 import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.RefState;
 import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.proto.Protos;
@@ -71,6 +73,7 @@
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gson.Gson;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -84,6 +87,7 @@
 import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
@@ -153,7 +157,7 @@
   public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
       timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
           .stored()
-          .build(cd -> cd.getMergedOn().orElse(null));
+          .build(cd -> cd.getMergedOn().orElse(null), (cd, field) -> cd.setMergedOn(field));
 
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
@@ -188,7 +192,12 @@
   public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
       storedOnly("_hashtag")
           .buildRepeatable(
-              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()));
+              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())));
 
   /** Components of each file path modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
@@ -335,7 +344,14 @@
    */
   public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
       storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
-          .buildRepeatable(ChangeField::storedAttentionSet);
+          .buildRepeatable(
+              ChangeField::storedAttentionSet,
+              (cd, value) ->
+                  parseAttentionSet(
+                      StreamSupport.stream(value.spliterator(), false)
+                          .map(v -> new String(v, UTF_8))
+                          .collect(toImmutableSet()),
+                      cd));
 
   /** The user assigned to the change. */
   public static final FieldDef<ChangeData, Integer> ASSIGNEE =
@@ -344,25 +360,38 @@
 
   /** Reviewer(s) associated with the change. */
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
-      exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
+      exact("reviewer2")
+          .stored()
+          .buildRepeatable(
+              cd -> getReviewerFieldValues(cd.reviewers()),
+              (cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) associated with the change that do not have a gerrit account. */
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
       exact("reviewer_by_email")
           .stored()
-          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
+          .buildRepeatable(
+              cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
+              (cd, field) ->
+                  cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) modified during change's current WIP phase. */
   public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
       exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
           .stored()
-          .buildRepeatable(cd -> getReviewerFieldValues(cd.pendingReviewers()));
+          .buildRepeatable(
+              cd -> getReviewerFieldValues(cd.pendingReviewers()),
+              (cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) by email modified during change's current WIP phase. */
   public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
       exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
           .stored()
-          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()));
+          .buildRepeatable(
+              cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
+              (cd, field) ->
+                  cd.setPendingReviewersByEmail(
+                      parseReviewerByEmailFieldValues(cd.getId(), field)));
 
   /** References a change that this change reverts. */
   public static final FieldDef<ChangeData, Integer> REVERT_OF =
@@ -642,13 +671,18 @@
   /** Serialized change object, used for pre-populating results. */
   public static final FieldDef<ChangeData, byte[]> CHANGE =
       storedOnly("_change")
-          .build(changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)));
+          .build(
+              changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)),
+              (cd, field) -> cd.setChange(parseProtoFrom(field, ChangeProtoConverter.INSTANCE)));
 
   /** Serialized approvals for the current patch set, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
       storedOnly("_approval")
           .buildRepeatable(
-              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()));
+              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
+              (cd, field) ->
+                  cd.setCurrentApprovals(
+                      decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, null);
@@ -689,11 +723,14 @@
   /** Number of unresolved comment threads of the change, including robot comments. */
   public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
       intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
-          .build(ChangeData::unresolvedCommentCount);
+          .build(
+              ChangeData::unresolvedCommentCount,
+              (cd, field) -> cd.setUnresolvedCommentCount(field));
 
   /** Total number of published inline comments of the change, including robot comments. */
   public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
-      intRange("total_comments").build(ChangeData::totalCommentCount);
+      intRange("total_comments")
+          .build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
 
   /** Whether the change is mergeable. */
   public static final FieldDef<ChangeData, String> MERGEABLE =
@@ -706,7 +743,8 @@
                   return null;
                 }
                 return m ? "1" : "0";
-              });
+              },
+              (cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
 
   /** Whether the change is a merge commit. */
   public static final FieldDef<ChangeData, String> MERGE =
@@ -724,12 +762,16 @@
   /** The number of inserted lines in this change. */
   public static final FieldDef<ChangeData, Integer> ADDED =
       intRange(ChangeQueryBuilder.FIELD_ADDED)
-          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null);
+          .build(
+              cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
+              (cd, field) -> cd.setLinesInserted(field));
 
   /** The number of deleted lines in this change. */
   public static final FieldDef<ChangeData, Integer> DELETED =
       intRange(ChangeQueryBuilder.FIELD_DELETED)
-          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null);
+          .build(
+              cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null,
+              (cd, field) -> cd.setLinesDeleted(field));
 
   /** The total number of modified lines in this change. */
   public static final FieldDef<ChangeData, Integer> DELTA =
@@ -770,8 +812,12 @@
                   Iterables.transform(
                       cd.stars().entries(),
                       e ->
-                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue())
-                              .toString()));
+                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue()).toString()),
+              (cd, field) ->
+                  cd.setStars(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> StarredChangesUtil.StarField.parse(f))
+                          .collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
 
   /** Users that have starred the change with any label. */
   public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
@@ -787,7 +833,9 @@
   /** Serialized patch set object, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
       storedOnly("_patch_set")
-          .buildRepeatable(cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()));
+          .buildRepeatable(
+              cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
+              (cd, field) -> cd.setPatchSets(decodeProtos(field, PatchSetProtoConverter.INSTANCE)));
 
   /** Users who have edits on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
@@ -821,7 +869,12 @@
                   return ImmutableSet.of(NOT_REVIEWED);
                 }
                 return reviewedBy.stream().map(Account.Id::get).collect(toList());
-              });
+              },
+              (cd, field) ->
+                  cd.setReviewedBy(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(Account::id)
+                          .collect(toImmutableSet())));
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
       SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
@@ -917,11 +970,27 @@
 
   public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
       storedOnly("full_submit_record_strict")
-          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT));
+          .buildRepeatable(
+              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT),
+              (cd, field) ->
+                  parseSubmitRecords(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> new String(f, UTF_8))
+                          .collect(toSet()),
+                      SUBMIT_RULE_OPTIONS_STRICT,
+                      cd));
 
   public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
       storedOnly("full_submit_record_lenient")
-          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT));
+          .buildRepeatable(
+              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
+              (cd, field) ->
+                  parseSubmitRecords(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> new String(f, UTF_8))
+                          .collect(toSet()),
+                      SUBMIT_RULE_OPTIONS_LENIENT,
+                      cd));
 
   public static void parseSubmitRecords(
       Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
@@ -987,7 +1056,8 @@
                     .entries()
                     .forEach(e -> result.add(e.getValue().toByteArray(e.getKey())));
                 return result;
-              });
+              },
+              (cd, field) -> cd.setRefStates(RefState.parseStates(field)));
 
   /**
    * All ref wildcard patterns that were used in the course of indexing this document.
@@ -1013,7 +1083,8 @@
                     RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
                         .toByteArray(allUsers(cd)));
                 return result;
-              });
+              },
+              (cd, field) -> cd.setRefStatePatterns(field));
 
   private static String getTopic(ChangeData cd) {
     Change c = cd.change();
@@ -1031,6 +1102,18 @@
     return Protos.toByteArray(converter.toProto(object));
   }
 
+  private static <T> List<T> decodeProtos(Iterable<byte[]> raw, ProtoConverter<?, T> converter) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(bytes -> parseProtoFrom(bytes, converter))
+        .collect(toImmutableList());
+  }
+
+  private static <P extends MessageLite, T> T parseProtoFrom(
+      byte[] bytes, ProtoConverter<P, T> converter) {
+    P message = Protos.parseUnchecked(converter.getParser(), bytes, 0, bytes.length);
+    return converter.fromProto(message);
+  }
+
   private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
     return in -> in.change() != null ? func.apply(in.change()) : null;
   }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index 793e4ec..dcc3ca6 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -471,6 +471,26 @@
     changedLines = Optional.of(new ChangedLines(insertions, deletions));
   }
 
+  public void setLinesInserted(int insertions) {
+    changedLines =
+        Optional.of(
+            new ChangedLines(
+                insertions,
+                changedLines != null && changedLines.isPresent()
+                    ? changedLines.get().deletions
+                    : -1));
+  }
+
+  public void setLinesDeleted(int deletions) {
+    changedLines =
+        Optional.of(
+            new ChangedLines(
+                changedLines != null && changedLines.isPresent()
+                    ? changedLines.get().insertions
+                    : -1,
+                deletions));
+  }
+
   public void setNoChangedLines() {
     changedLines = Optional.empty();
   }