protobuf: Support custom ProtobufCodec implementations

Some object types might not be annotated with @Column, but we may
still want to embed them into a protobuf format.  Treat these as
opaque binary messages using the length delimited message format,
encoding them like any other nested message or arbitrary binary.

Change-Id: I8139e36838cbb2eabacbf3eab8c0156ddbdeb29b
Signed-off-by: Shawn O. Pearce <sop@google.com>
diff --git a/src/main/java/com/google/gwtorm/protobuf/CodecGen.java b/src/main/java/com/google/gwtorm/protobuf/CodecGen.java
index 0ddc642..e8598ae 100644
--- a/src/main/java/com/google/gwtorm/protobuf/CodecGen.java
+++ b/src/main/java/com/google/gwtorm/protobuf/CodecGen.java
@@ -312,9 +312,20 @@
     Class clazz = f.getNestedClass();
     NestedCodec n = nestedCodecs.get(clazz);
     if (n == null) {
-      n = new NestedCodec("codec" + f.getColumnID(), //
-          CodecFactory.encoder(clazz).getClass(), //
-          Type.getType(clazz));
+      Class<? extends ProtobufCodec> codec = null;
+      Type type = Type.getType(clazz);
+      if (f.getField() != null) {
+        final CustomCodec cc = f.getField().getAnnotation(CustomCodec.class);
+        if (cc != null) {
+          codec = cc.value();
+          type = object;
+        }
+      }
+      if (codec == null) {
+        codec = CodecFactory.encoder(clazz).getClass();
+      }
+
+      n = new NestedCodec("codec" + f.getColumnID(), codec, type);
       nestedCodecs.put(clazz, n);
     }
     return n;
@@ -390,7 +401,8 @@
 
   private JavaColumnModel collectionColumn(final JavaColumnModel f,
       final Class<?> valClazz) throws OrmException {
-    return new JavaColumnModel(//
+    return new JavaColumnModel( //
+        f.getField(), //
         f.getPathToFieldName(), //
         f.getColumnID(), //
         valClazz);
@@ -886,6 +898,10 @@
           .getDescriptor());
       mv.visitMethodInsn(INVOKEVIRTUAL, n.codecType.getInternalName(),
           "newInstance", Type.getMethodDescriptor(n.pojoType, new Type[] {}));
+      if (object.equals(n.pojoType)) {
+        mv.visitTypeInsn(CHECKCAST, Type.getType(f.getNestedClass())
+            .getInternalName());
+      }
       cgs.fieldSetEnd();
 
       // read the length, set a new limit, decode the message, validate
diff --git a/src/main/java/com/google/gwtorm/protobuf/CustomCodec.java b/src/main/java/com/google/gwtorm/protobuf/CustomCodec.java
new file mode 100644
index 0000000..3462a3c
--- /dev/null
+++ b/src/main/java/com/google/gwtorm/protobuf/CustomCodec.java
@@ -0,0 +1,34 @@
+// Copyright 2008 Google Inc.
+//
+// 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.gwtorm.protobuf;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Identity of a custom {@link ProtobufCodec} for a {@code Column}.
+ * <p>
+ * Additional annotation tagged onto a {@code Column} field that carries the
+ * name of a custom {@link ProtobufCodec} that should be used to handle that
+ * field. The field data will be treated as an opaque binary sequence, so its
+ * {@link ProtobufCodec#sizeof(Object)} method must be accurate.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface CustomCodec {
+  Class<? extends ProtobufCodec<?>> value();
+}
diff --git a/src/main/java/com/google/gwtorm/protobuf/ProtobufCodec.java b/src/main/java/com/google/gwtorm/protobuf/ProtobufCodec.java
index 0627738..e024be3 100644
--- a/src/main/java/com/google/gwtorm/protobuf/ProtobufCodec.java
+++ b/src/main/java/com/google/gwtorm/protobuf/ProtobufCodec.java
@@ -57,8 +57,12 @@
   /** Encode the object into a byte array. */
   public void encode(T obj, final byte[] data, int offset, int length) {
     CodedOutputStream out = CodedOutputStream.newInstance(data, offset, length);
-    encode(obj, out);
-    flush(out);
+    try {
+      encode(obj, out);
+      out.flush();
+    } catch (IOException err) {
+      throw new RuntimeException("Cannot encode message", err);
+    }
   }
 
   /** Encode the object into a ByteBuffer. */
@@ -68,22 +72,22 @@
           buf.array(), //
           buf.position(), //
           buf.remaining());
-      encode(obj, out);
-      flush(out);
+      try {
+        encode(obj, out);
+        out.flush();
+      } catch (IOException err) {
+        throw new RuntimeException("Cannot encode message", err);
+      }
       buf.position(buf.position() + (buf.remaining() - out.spaceLeft()));
 
     } else {
       CodedOutputStream out = CodedOutputStream.newInstance(newStream(buf));
-      encode(obj, out);
-      flush(out);
-    }
-  }
-
-  private static void flush(CodedOutputStream out) {
-    try {
-      out.flush();
-    } catch (IOException err) {
-      throw new RuntimeException("Cannot flush to in-memory buffer", err);
+      try {
+        encode(obj, out);
+        out.flush();
+      } catch (IOException err) {
+        throw new RuntimeException("Cannot encode message", err);
+      }
     }
   }
 
@@ -100,8 +104,9 @@
    *
    * @param obj the object to encode.
    * @param out the stream to encode the object onto.
+   * @throws IOException the underlying stream cannot be written to.
    */
-  public abstract void encode(T obj, CodedOutputStream out);
+  public abstract void encode(T obj, CodedOutputStream out) throws IOException;
 
   /** Compute the number of bytes of the encoded form of the object. */
   public abstract int sizeof(T obj);
@@ -111,7 +116,11 @@
 
   /** Decode a byte string into an object instance. */
   public T decode(ByteString buf) {
-    return decode(buf.newCodedInput());
+    try {
+      return decode(buf.newCodedInput());
+    } catch (IOException err) {
+      throw new RuntimeException("Cannot decode message", err);
+    }
   }
 
   /** Decode a byte array into an object instance. */
@@ -121,7 +130,11 @@
 
   /** Decode a byte array into an object instance. */
   public T decode(byte[] data, int offset, int length) {
-    return decode(CodedInputStream.newInstance(data, offset, length));
+    try {
+      return decode(CodedInputStream.newInstance(data, offset, length));
+    } catch (IOException err) {
+      throw new RuntimeException("Cannot decode message", err);
+    }
   }
 
   /** Decode a byte buffer into an object instance. */
@@ -131,7 +144,12 @@
           buf.array(), //
           buf.position(), //
           buf.remaining());
-      T obj = decode(in);
+      T obj;
+      try {
+        obj = decode(in);
+      } catch (IOException err) {
+        throw new RuntimeException("Cannot decode message", err);
+      }
       buf.position(buf.position() + in.getTotalBytesRead());
       return obj;
     } else {
@@ -139,13 +157,21 @@
     }
   }
 
-  /** Decode an object by reading it from the stream. */
-  public T decode(CodedInputStream in) {
+  /**
+   * Decode an object by reading it from the stream.
+   *
+   * @throws IOException the underlying stream cannot be read.
+   */
+  public T decode(CodedInputStream in) throws IOException {
     T obj = newInstance();
     mergeFrom(in, obj);
     return obj;
   }
 
-  /** Decode an input stream into an existing object instance. */
-  public abstract void mergeFrom(CodedInputStream in, T obj);
+  /**
+   * Decode an input stream into an existing object instance.
+   *
+   * @throws IOException the underlying stream cannot be read.
+   */
+  public abstract void mergeFrom(CodedInputStream in, T obj) throws IOException;
 }
diff --git a/src/main/java/com/google/gwtorm/schema/ColumnModel.java b/src/main/java/com/google/gwtorm/schema/ColumnModel.java
index df55a85..72bf017 100644
--- a/src/main/java/com/google/gwtorm/schema/ColumnModel.java
+++ b/src/main/java/com/google/gwtorm/schema/ColumnModel.java
@@ -57,11 +57,6 @@
 
   protected void initNestedColumns(final Collection<? extends ColumnModel> col)
       throws OrmException {
-    if (col == null || col.isEmpty()) {
-      throw new OrmException("Field " + getPathToFieldName()
-          + " has no nested members inside type " + getNestedClassName());
-    }
-
     nestedColumns = new ArrayList<ColumnModel>(col);
     recomputeColumnNames();
     if (!isNotNull()) {
diff --git a/src/main/java/com/google/gwtorm/schema/java/JavaColumnModel.java b/src/main/java/com/google/gwtorm/schema/java/JavaColumnModel.java
index 479b689..9aebf5b 100644
--- a/src/main/java/com/google/gwtorm/schema/java/JavaColumnModel.java
+++ b/src/main/java/com/google/gwtorm/schema/java/JavaColumnModel.java
@@ -29,11 +29,13 @@
 
 
 public class JavaColumnModel extends ColumnModel {
+  private final Field field;
   private final String fieldName;
   private final Class<?> primitiveType;
   private final Type genericType;
 
-  public JavaColumnModel(final Field field) throws OrmException {
+  public JavaColumnModel(final Field f) throws OrmException {
+    field = f;
     fieldName = field.getName();
     primitiveType = field.getType();
     genericType = field.getGenericType();
@@ -58,8 +60,9 @@
     initNested();
   }
 
-  public JavaColumnModel(final String fieldPath, final int columnId,
+  public JavaColumnModel(Field f, final String fieldPath, final int columnId,
       final Class<?> columnType) throws OrmException {
+    this.field = f;
     this.fieldName = fieldPath;
     this.columnName = fieldPath;
     this.columnId = columnId;
@@ -117,6 +120,10 @@
     return primitiveType;
   }
 
+  public Field getField() {
+    return field;
+  }
+
   private boolean isPrimitive() {
     return Util.isSqlPrimitive(primitiveType);
   }
diff --git a/src/test/java/com/google/gwtorm/protobuf/ProtobufEncoderTest.java b/src/test/java/com/google/gwtorm/protobuf/ProtobufEncoderTest.java
index 216f793..14544c0 100644
--- a/src/test/java/com/google/gwtorm/protobuf/ProtobufEncoderTest.java
+++ b/src/test/java/com/google/gwtorm/protobuf/ProtobufEncoderTest.java
@@ -17,9 +17,12 @@
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.data.TestAddress;
 import com.google.gwtorm.data.TestPerson;
+import com.google.protobuf.CodedInputStream;
+import com.google.protobuf.CodedOutputStream;
 
 import junit.framework.TestCase;
 
+import java.io.IOException;
 import java.io.UnsupportedEncodingException;
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
@@ -181,6 +184,19 @@
     assertEquals(list.people, other.people);
   }
 
+  public void testCustomEncoderList() {
+    ProtobufCodec<ItemList> e = CodecFactory.encoder(ItemList.class);
+
+    ItemList list = new ItemList();
+    list.list = new ArrayList<Item>();
+    list.list.add(new Item());
+    list.list.add(new Item());
+
+    ItemList other = e.decode(e.encodeToByteArray(list));
+    assertNotNull(other.list);
+    assertEquals(2, other.list.size());
+  }
+
   private static String asString(byte[] bin)
       throws UnsupportedEncodingException {
     return new String(bin, "ISO-8859-1");
@@ -200,4 +216,35 @@
     @Column(id = 1)
     SortedSet<String> list;
   }
+
+  static class Item {
+  }
+
+  static class ItemCodec extends ProtobufCodec<Item> {
+    @Override
+    public void encode(Item obj, CodedOutputStream out) throws IOException {
+      out.writeBoolNoTag(true);
+    }
+
+    @Override
+    public void mergeFrom(CodedInputStream in, Item obj) throws IOException {
+      in.readBool();
+    }
+
+    @Override
+    public Item newInstance() {
+      return new Item();
+    }
+
+    @Override
+    public int sizeof(Item obj) {
+      return 1;
+    }
+  }
+
+  static class ItemList {
+    @Column(id = 2)
+    @CustomCodec(ItemCodec.class)
+    List<Item> list;
+  }
 }