Add converter for ChangeMessage protobuf messages

The use of ProtobufCodec requires that value classes need to be
annotated with @Column, which won't be possible as soon as we have
removed gwtorm. Hence, provide a hand-written converter for
ChangeMessage protobuf messages.

As protobuf ChangeMessage are currently used in caches and we
don't want to invalidate those, we have to ensure binary compatibility.
Prove that the new converter generates binary compatible results via
dedicated tests. Those tests will be removed after this change.

Change-Id: I7b83152597e7b3d8d4f017a07de37b1760bf3dbc
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
new file mode 100644
index 0000000..f8667f1
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverter.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2018 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.protobuf.Parser;
+
+public enum ChangeMessageKeyProtoConverter
+    implements ProtoConverter<Reviewdb.ChangeMessage_Key, ChangeMessage.Key> {
+  INSTANCE;
+
+  private final ProtoConverter<Reviewdb.Change_Id, Change.Id> changeIdConverter =
+      ChangeIdProtoConverter.INSTANCE;
+
+  @Override
+  public Reviewdb.ChangeMessage_Key toProto(ChangeMessage.Key messageKey) {
+    return Reviewdb.ChangeMessage_Key.newBuilder()
+        .setChangeId(changeIdConverter.toProto(messageKey.getParentKey()))
+        .setUuid(messageKey.get())
+        .build();
+  }
+
+  @Override
+  public ChangeMessage.Key fromProto(Reviewdb.ChangeMessage_Key proto) {
+    return new ChangeMessage.Key(changeIdConverter.fromProto(proto.getChangeId()), proto.getUuid());
+  }
+
+  @Override
+  public Parser<Reviewdb.ChangeMessage_Key> getParser() {
+    return Reviewdb.ChangeMessage_Key.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
new file mode 100644
index 0000000..99d9ca7
--- /dev/null
+++ b/java/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverter.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2018 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.reviewdb.converter;
+
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+public enum ChangeMessageProtoConverter
+    implements ProtoConverter<Reviewdb.ChangeMessage, ChangeMessage> {
+  INSTANCE;
+
+  private final ProtoConverter<Reviewdb.ChangeMessage_Key, ChangeMessage.Key>
+      changeMessageKeyConverter = ChangeMessageKeyProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.Account_Id, Account.Id> accountIdConverter =
+      AccountIdProtoConverter.INSTANCE;
+  private final ProtoConverter<Reviewdb.PatchSet_Id, PatchSet.Id> patchSetIdConverter =
+      PatchSetIdProtoConverter.INSTANCE;
+
+  @Override
+  public Reviewdb.ChangeMessage toProto(ChangeMessage changeMessage) {
+    Reviewdb.ChangeMessage.Builder builder =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(changeMessageKeyConverter.toProto(changeMessage.getKey()));
+    Account.Id author = changeMessage.getAuthor();
+    if (author != null) {
+      builder.setAuthorId(accountIdConverter.toProto(author));
+    }
+    Timestamp writtenOn = changeMessage.getWrittenOn();
+    if (writtenOn != null) {
+      builder.setWrittenOn(writtenOn.getTime());
+    }
+    String message = changeMessage.getMessage();
+    if (message != null) {
+      builder.setMessage(message);
+    }
+    PatchSet.Id patchSetId = changeMessage.getPatchSetId();
+    if (patchSetId != null) {
+      builder.setPatchset(patchSetIdConverter.toProto(patchSetId));
+    }
+    String tag = changeMessage.getTag();
+    if (tag != null) {
+      builder.setTag(tag);
+    }
+    Account.Id realAuthor = changeMessage.getRealAuthor();
+    // ChangeMessage#getRealAuthor automatically delegates to ChangeMessage#getAuthor if the real
+    // author is not set. However, the previous protobuf representation kept 'realAuthor' empty if
+    // it wasn't set. To ensure binary compatibility, simulate the previous behavior.
+    if (realAuthor != null && !Objects.equals(realAuthor, author)) {
+      builder.setRealAuthor(accountIdConverter.toProto(realAuthor));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public ChangeMessage fromProto(Reviewdb.ChangeMessage proto) {
+    ChangeMessage.Key key =
+        proto.hasKey() ? changeMessageKeyConverter.fromProto(proto.getKey()) : null;
+    Account.Id author =
+        proto.hasAuthorId() ? accountIdConverter.fromProto(proto.getAuthorId()) : null;
+    Timestamp writtenOn = proto.hasWrittenOn() ? new Timestamp(proto.getWrittenOn()) : null;
+    PatchSet.Id patchSetId =
+        proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
+    ChangeMessage changeMessage = new ChangeMessage(key, author, writtenOn, patchSetId);
+    if (proto.hasMessage()) {
+      changeMessage.setMessage(proto.getMessage());
+    }
+    if (proto.hasTag()) {
+      changeMessage.setTag(proto.getTag());
+    }
+    if (proto.hasRealAuthor()) {
+      changeMessage.setRealAuthor(accountIdConverter.fromProto(proto.getRealAuthor()));
+    }
+    return changeMessage;
+  }
+
+  @Override
+  public Parser<Reviewdb.ChangeMessage> getParser() {
+    return Reviewdb.ChangeMessage.parser();
+  }
+}
diff --git a/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java b/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
index be83ce4..14d5cd7 100644
--- a/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
+++ b/java/com/google/gerrit/reviewdb/server/ReviewDbCodecs.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.reviewdb.server;
 
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gwtorm.protobuf.CodecFactory;
 import com.google.gwtorm.protobuf.ProtobufCodec;
@@ -27,8 +26,5 @@
 
   public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
 
-  public static final ProtobufCodec<ChangeMessage> MESSAGE_CODEC =
-      CodecFactory.encoder(ChangeMessage.class);
-
   private ReviewDbCodecs() {}
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 0828dbb..4566424 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -20,7 +20,6 @@
 import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
@@ -48,6 +47,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
 import com.google.gerrit.reviewdb.converter.ProtoConverter;
 import com.google.gerrit.server.OutputFormat;
@@ -488,7 +488,7 @@
           .forEach(r -> b.addSubmitRecord(GSON.toJson(new StoredSubmitRecord(r))));
       object
           .changeMessages()
-          .forEach(m -> b.addChangeMessage(Protos.toByteString(m, MESSAGE_CODEC)));
+          .forEach(m -> b.addChangeMessage(toByteString(m, ChangeMessageProtoConverter.INSTANCE)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
 
       if (object.readOnlyUntil() != null) {
@@ -619,7 +619,7 @@
                   proto
                       .getChangeMessageList()
                       .stream()
-                      .map(MESSAGE_CODEC::decode)
+                      .map(bytes -> parseProtoFrom(ChangeMessageProtoConverter.INSTANCE, bytes))
                       .collect(toImmutableList()))
               .publishedComments(
                   proto
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageConverterCompatibilityTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageConverterCompatibilityTest.java
new file mode 100644
index 0000000..a194ec6
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageConverterCompatibilityTest.java
@@ -0,0 +1,195 @@
+// Copyright (C) 2018 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.reviewdb.converter;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwtorm.protobuf.CodecFactory;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.protobuf.ByteString;
+import com.google.protobuf.CodedOutputStream;
+import com.google.protobuf.MessageLite;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Collection;
+import java.util.List;
+import org.junit.Test;
+
+// TODO(aliceks): Delete after proving binary compatibility.
+public class ChangeMessageConverterCompatibilityTest {
+
+  private final ProtobufCodec<ChangeMessage> changeMessageCodec =
+      CodecFactory.encoder(ChangeMessage.class);
+  private final ChangeMessageProtoConverter changeMessageProtoConverter =
+      ChangeMessageProtoConverter.INSTANCE;
+
+  @Test
+  public void changeIndexFieldWithAllValuesIsBinaryCompatible() throws Exception {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(new Account.Id(10003));
+    ImmutableList<ChangeMessage> changeMessages = ImmutableList.of(changeMessage);
+
+    byte[] resultOfOldConverter =
+        getOnlyElement(convertToProtos_old(changeMessageCodec, changeMessages));
+    byte[] resultOfNewConverter =
+        getOnlyElement(convertToProtos_new(changeMessageProtoConverter, changeMessages));
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeIndexFieldWithMandatoryValuesIsBinaryCompatible() throws Exception {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+    ImmutableList<ChangeMessage> changeMessages = ImmutableList.of(changeMessage);
+
+    byte[] resultOfOldConverter =
+        getOnlyElement(convertToProtos_old(changeMessageCodec, changeMessages));
+    byte[] resultOfNewConverter =
+        getOnlyElement(convertToProtos_new(changeMessageProtoConverter, changeMessages));
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithAllValuesIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(new Account.Id(10003));
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithMainValuesIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithoutRealAuthorButAuthorIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            null,
+            null);
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithoutSameRealAuthorAndAuthorIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            null,
+            null);
+    changeMessage.setRealAuthor(new Account.Id(63));
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  @Test
+  public void changeNotesFieldWithMandatoryValuesIsBinaryCompatible() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+
+    ByteString resultOfOldConverter = Protos.toByteString(changeMessage, changeMessageCodec);
+    ByteString resultOfNewConverter = toByteString(changeMessage, changeMessageProtoConverter);
+
+    assertThat(resultOfNewConverter).isEqualTo(resultOfOldConverter);
+  }
+
+  // Copied from ChangeField.
+  private static <T> List<byte[]> convertToProtos_old(ProtobufCodec<T> codec, Collection<T> objs)
+      throws OrmException {
+    List<byte[]> result = Lists.newArrayListWithCapacity(objs.size());
+    ByteArrayOutputStream out = new ByteArrayOutputStream(256);
+    try {
+      for (T obj : objs) {
+        out.reset();
+        CodedOutputStream cos = CodedOutputStream.newInstance(out);
+        codec.encode(obj, cos);
+        cos.flush();
+        result.add(out.toByteArray());
+      }
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return result;
+  }
+
+  // Copied from ChangeField.
+  private static <T> List<byte[]> convertToProtos_new(
+      ProtoConverter<?, T> converter, Collection<T> objects) {
+    return objects
+        .stream()
+        .map(converter::toProto)
+        .map(Protos::toByteArray)
+        .collect(toImmutableList());
+  }
+
+  // Copied from ChangeNotesState.Serializer.
+  private static <T> ByteString toByteString(T object, ProtoConverter<?, T> converter) {
+    MessageLite message = converter.toProto(object);
+    return Protos.toByteString(message);
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
new file mode 100644
index 0000000..9874737
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageKeyProtoConverterTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2018 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import org.junit.Test;
+
+public class ChangeMessageKeyProtoConverterTest {
+  private final ChangeMessageKeyProtoConverter messageKeyProtoConverter =
+      ChangeMessageKeyProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ChangeMessage.Key messageKey = new ChangeMessage.Key(new Change.Id(704), "aabbcc");
+
+    Reviewdb.ChangeMessage_Key proto = messageKeyProtoConverter.toProto(messageKey);
+
+    Reviewdb.ChangeMessage_Key expectedProto =
+        Reviewdb.ChangeMessage_Key.newBuilder()
+            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(704))
+            .setUuid("aabbcc")
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage.Key messageKey = new ChangeMessage.Key(new Change.Id(704), "aabbcc");
+
+    ChangeMessage.Key convertedMessageKey =
+        messageKeyProtoConverter.fromProto(messageKeyProtoConverter.toProto(messageKey));
+
+    assertThat(convertedMessageKey).isEqualTo(messageKey);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.ChangeMessage_Key proto =
+        Reviewdb.ChangeMessage_Key.newBuilder()
+            .setChangeId(Reviewdb.Change_Id.newBuilder().setId(704))
+            .setUuid("aabbcc")
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.ChangeMessage_Key> parser = messageKeyProtoConverter.getParser();
+    Reviewdb.ChangeMessage_Key parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(ChangeMessage.Key.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("changeId", Change.Id.class)
+                .put("uuid", String.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
new file mode 100644
index 0000000..f478deb
--- /dev/null
+++ b/javatests/com/google/gerrit/reviewdb/converter/ChangeMessageProtoConverterTest.java
@@ -0,0 +1,214 @@
+// Copyright (C) 2018 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.reviewdb.converter;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
+import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.proto.reviewdb.Reviewdb;
+import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.protobuf.Parser;
+import java.lang.reflect.Type;
+import java.sql.Timestamp;
+import org.junit.Test;
+
+public class ChangeMessageProtoConverterTest {
+  private final ChangeMessageProtoConverter changeMessageProtoConverter =
+      ChangeMessageProtoConverter.INSTANCE;
+
+  @Test
+  public void allValuesConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(new Account.Id(10003));
+
+    Reviewdb.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Reviewdb.ChangeMessage expectedProto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Reviewdb.Account_Id.newBuilder().setId(63))
+            .setWrittenOn(9876543)
+            .setMessage("This is a change message.")
+            .setPatchset(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(34))
+                    .setPatchSetId(13))
+            .setTag("An arbitrary tag.")
+            .setRealAuthor(Reviewdb.Account_Id.newBuilder().setId(10003))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mainValuesConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+
+    Reviewdb.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Reviewdb.ChangeMessage expectedProto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Reviewdb.Account_Id.newBuilder().setId(63))
+            .setWrittenOn(9876543)
+            .setPatchset(
+                Reviewdb.PatchSet_Id.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(34))
+                    .setPatchSetId(13))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  // This test documents a special behavior which is necessary to ensure binary compatibility.
+  @Test
+  public void realAuthorIsNotAutomaticallySetToAuthorWhenConvertedToProto() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            null,
+            null);
+
+    Reviewdb.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Reviewdb.ChangeMessage expectedProto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .setAuthorId(Reviewdb.Account_Id.newBuilder().setId(63))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProto() {
+    // writtenOn may not be null according to the column definition but it's optional for the
+    // protobuf definition. -> assume as optional and hence test null
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+
+    Reviewdb.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
+
+    Reviewdb.ChangeMessage expectedProto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .build();
+    assertThat(proto).isEqualTo(expectedProto);
+  }
+
+  @Test
+  public void allValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+    changeMessage.setMessage("This is a change message.");
+    changeMessage.setTag("An arbitrary tag.");
+    changeMessage.setRealAuthor(new Account.Id(10003));
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void mainValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"),
+            new Account.Id(63),
+            new Timestamp(9876543),
+            new PatchSet.Id(new Change.Id(34), 13));
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void mandatoryValuesConvertedToProtoAndBackAgain() {
+    ChangeMessage changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(new Change.Id(543), "change-message-21"), null, null, null);
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
+  public void protoCanBeParsedFromBytes() throws Exception {
+    Reviewdb.ChangeMessage proto =
+        Reviewdb.ChangeMessage.newBuilder()
+            .setKey(
+                Reviewdb.ChangeMessage_Key.newBuilder()
+                    .setChangeId(Reviewdb.Change_Id.newBuilder().setId(543))
+                    .setUuid("change-message-21"))
+            .build();
+    byte[] bytes = proto.toByteArray();
+
+    Parser<Reviewdb.ChangeMessage> parser = changeMessageProtoConverter.getParser();
+    Reviewdb.ChangeMessage parsedProto = parser.parseFrom(bytes);
+
+    assertThat(parsedProto).isEqualTo(proto);
+  }
+
+  /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
+  @Test
+  public void fieldsExistAsExpected() {
+    assertThatSerializedClass(ChangeMessage.class)
+        .hasFields(
+            ImmutableMap.<String, Type>builder()
+                .put("key", ChangeMessage.Key.class)
+                .put("author", Account.Id.class)
+                .put("writtenOn", Timestamp.class)
+                .put("message", String.class)
+                .put("patchset", PatchSet.Id.class)
+                .put("tag", String.class)
+                .put("realAuthor", Account.Id.class)
+                .build());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 80771a7..684e36a 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -19,7 +19,6 @@
 import static com.google.gerrit.proto.Protos.toByteString;
 import static com.google.gerrit.proto.testing.SerializedClassSubject.assertThatSerializedClass;
 import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.APPROVAL_CODEC;
-import static com.google.gerrit.reviewdb.server.ReviewDbCodecs.MESSAGE_CODEC;
 import static com.google.gerrit.server.notedb.ChangeNotesState.Serializer.toByteString;
 
 import com.google.common.collect.ImmutableList;
@@ -40,6 +39,7 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.reviewdb.converter.PatchSetProtoConverter;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -641,7 +641,7 @@
             new Account.Id(1000),
             new Timestamp(1212L),
             new PatchSet.Id(ID, 1));
-    ByteString m1Bytes = toByteString(m1, MESSAGE_CODEC);
+    ByteString m1Bytes = toByteString(m1, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
@@ -650,7 +650,7 @@
             new Account.Id(2000),
             new Timestamp(3434L),
             new PatchSet.Id(ID, 2));
-    ByteString m2Bytes = toByteString(m2, MESSAGE_CODEC);
+    ByteString m2Bytes = toByteString(m2, ChangeMessageProtoConverter.INSTANCE);
     assertThat(m2Bytes.size()).isEqualTo(35);
     assertThat(m2Bytes).isNotEqualTo(m1Bytes);