Store hashtags in change index

When changes are queried the hashtags are retured with each change.
These hashtags should be read from the change index rather than
loading them from NoteDb. Loading the change notes for each change in
the result set leads to poor query performance when NoteDb is enabled.

The existing HASHTAG field in the change index contains the lower case
hashtags to support case-insensitive queries by hashtag. To be able to
retrieve the hashtags with the original case from the index we must
add a new field that stores the hashtags with the original case. This
new field is not used for serving queries, hence it is of type
STORED_ONLY.

Change-Id: I0a9eb0ad0ac849d8b7b3ac6965dc8b5f38d04a32
Signed-off-by: Edwin Kempin <ekempin@google.com>
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index 3193c5e..1f1c80a 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -237,6 +237,14 @@
     assertMessage(r, "Hashtag removed: tag3");
   }
 
+  @Test
+  public void testHashtagWithMixedCase() throws Exception {
+    PushOneCommit.Result r = createChange();
+    addHashtags(r, "MyHashtag");
+    assertThatGet(r).containsExactly("MyHashtag");
+    assertMessage(r, "Hashtag added: MyHashtag");
+  }
+
   private IterableSubject<
         ? extends IterableSubject<?, String, Iterable<String>>,
         String, Iterable<String>>
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 840f8b4..f4fa0cb 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -114,6 +114,8 @@
   private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
   private static final String REVIEWEDBY_FIELD =
       ChangeField.REVIEWEDBY.getName();
+  private static final String HASHTAG_FIELD =
+      ChangeField.HASHTAG_CASE_AWARE.getName();
   private static final String STARREDBY_FIELD = ChangeField.STARREDBY.getName();
 
   static Term idTerm(ChangeData cd) {
@@ -414,6 +416,9 @@
     if (fields.contains(REVIEWEDBY_FIELD)) {
       decodeReviewedBy(doc, cd);
     }
+    if (fields.contains(HASHTAG_FIELD)) {
+      decodeHashtags(doc, cd);
+    }
     if (fields.contains(STARREDBY_FIELD)) {
       decodeStarredBy(doc, cd);
     }
@@ -470,6 +475,15 @@
     }
   }
 
+  private void decodeHashtags(Document doc, ChangeData cd) {
+    IndexableField[] hashtag = doc.getFields(HASHTAG_FIELD);
+    Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtag.length);
+    for (IndexableField r : hashtag) {
+      hashtags.add(r.binaryValue().utf8ToString());
+    }
+    cd.setHashtags(hashtags);
+  }
+
   private void decodeStarredBy(Document doc, ChangeData cd) {
     IndexableField[] starredBy = doc.getFields(STARREDBY_FIELD);
     Set<Account.Id> accounts =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index a0d3fac..bcbcff8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -394,7 +394,7 @@
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
     out.topic = in.getTopic();
-    out.hashtags = ctl.getNotes().load().getHashtags();
+    out.hashtags = cd.hashtags();
     out.changeId = in.getKey().get();
     if (in.getStatus() != Change.Status.MERGED) {
       SubmitTypeRecord str = cd.submitTypeRecord();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index 67694ac..e62e665 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.index.change;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Function;
 import com.google.common.base.Splitter;
@@ -237,14 +238,29 @@
         @Override
         public Iterable<String> get(ChangeData input, FillArgs args)
             throws OrmException {
-          return ImmutableSet.copyOf(Iterables.transform(input.notes().load()
-              .getHashtags(), new Function<String, String>() {
-
+          return ImmutableSet.copyOf(Iterables.transform(input.hashtags(),
+              new Function<String, String>() {
             @Override
             public String apply(String input) {
               return input.toLowerCase();
             }
+          }));
+        }
+      };
 
+  /** Hashtags with original case. */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
+      new FieldDef.Repeatable<ChangeData, byte[]>(
+          "_hashtag", FieldType.STORED_ONLY, true) {
+        @Override
+        public Iterable<byte[]> get(ChangeData input, FillArgs args)
+            throws OrmException {
+          return ImmutableSet.copyOf(Iterables.transform(input.hashtags(),
+              new Function<String, byte[]>() {
+            @Override
+            public byte[] apply(String hashtag) {
+              return hashtag.getBytes(UTF_8);
+            }
           }));
         }
       };
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 9fbe51f..8fb9000 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -64,8 +64,12 @@
   @Deprecated
   static final Schema<ChangeData> V27 = schema(V26.getFields().values());
 
+  @Deprecated
   static final Schema<ChangeData> V28 = schema(V27, ChangeField.STARREDBY);
 
+  static final Schema<ChangeData> V29 =
+      schema(V28, ChangeField.HASHTAG_CASE_AWARE);
+
   public static final ChangeSchemaDefinitions INSTANCE =
       new ChangeSchemaDefinitions();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index 452d51f..4d94828 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -337,6 +337,7 @@
   private ChangedLines changedLines;
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
+  private Set<String> hashtags;
   private Set<Account.Id> editsByUser;
   private Set<Account.Id> reviewedBy;
   private Set<Account.Id> draftsByUser;
@@ -1018,6 +1019,17 @@
     this.reviewedBy = reviewedBy;
   }
 
+  public Set<String> hashtags() throws OrmException {
+    if (hashtags == null) {
+      hashtags = notes().getHashtags();
+    }
+    return hashtags;
+  }
+
+  public void setHashtags(Set<String> hashtags) {
+    this.hashtags = hashtags;
+  }
+
   public Set<Account.Id> starredBy() throws OrmException {
     if (starredByUser == null) {
       starredByUser = starredChangesUtil.byChange(legacyId);