This is an automated email from the ASF dual-hosted git repository.

wgtmac pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/parquet-java.git


The following commit(s) were added to refs/heads/master by this push:
     new c3def131d GH-2142: Write fields of empty message types as proto bytes 
(#3750)
c3def131d is described below

commit c3def131da9f09d8bcf3968cf076f410fdcf5309
Author: Peter Puškár <[email protected]>
AuthorDate: Wed Sep 23 17:33:48 2026 +0200

    GH-2142: Write fields of empty message types as proto bytes (#3750)
    
    ### Rationale for this change
    
    Protobuf allows empty message definitions, but Parquet forbids empty 
groups. Converting a message
    that merely *contains* a field of an empty message type produces a schema 
with an empty group,
    which writer construction rejects with `InvalidSchemaException: Cannot 
write a schema with an
    empty group`. Such fields appear in real-world schemas (deprecated stubs, 
marker/placeholder
    messages), and a single one makes the whole message type unwritable.
    
    ### What changes are included in this PR?
    
    `ProtoSchemaConverter.addMessageField` terminates a field whose message 
type has no fields as a
    `BINARY` column holding the serialized message — the same mechanism 
PARQUET-1711 uses for
    recursion beyond `maxRecursion`. Since an empty message serializes to zero 
bytes, the column is
    cheap, and field **presence** still round-trips (null = unset vs empty 
bytes = set):
    
    - singular field → `optional binary stub` (or `required`, per the field);
    - repeated field, parquet-specs mode → LIST-wrapped binary via the existing
      `addRepeatedPrimitive`, so element cardinality survives;
    - repeated field, old style → `repeated binary`;
    - map value type → `optional binary value` inside the `key_value` group 
(keys stay typed).
    
    `ProtoWriteSupport.createMessageWriter`'s existing truncated-field check 
(primitive BINARY where a
    message field was declared → `BinaryWriter`) is generalized to look through 
the LIST/MAP wrapper
    (`getGroupType` → `getContentType`), so the writer tree lines up with these 
schemas.
    
    A message that is empty at the **root** is still rejected — there is no 
parent field to hold the
    bytes, and a Parquet file with zero columns is not representable.
    
    **Read side** (second commit, after review): `ProtoMessageConverter` used 
to cast every message
    field's Parquet type to a group, so a message field stored as `BINARY` 
failed with a
    `ClassCastException` while the converter tree was built — which also means 
files with
    PARQUET-1711 recursion truncation have been unreadable by 
`ProtoParquetReader` since 1.13.0 (#995
    shipped with an explicit "TODO: ReadSupport"). A new 
`ProtoBinaryMessageConverter` parses the
    bytes back through `parentBuilder.newBuilderForField(field)` and hands the 
message to the existing
    parent container, so singular fields, LIST elements, old-style repeated 
fields and map values all
    round-trip without touching `ListConverter`/`MapConverter`. Parse failures 
surface as
    `ParquetDecodingException`.
    
    ### Are these changes tested?
    
    Yes. New `ProtoEmptyMessageTest` (new test messages `Stub`/`StubBox` in 
`Trees.proto`) writes
    through the real write path (`ProtoParquetWriter` → `MessageColumnIO`, both 
specs-compliant and
    old style) and reads back with `GroupReadSupport` and with 
`ProtoParquetReader`:
    
    - singular / repeated / map-value empty-message fields round-trip with 
correct cardinality and
      zero-byte values;
    - presence round-trips (set empty message vs unset field);
    - `ProtoParquetReader` reads the written messages back equal to the 
originals (specs-compliant and
      old style), and the same read path round-trips a `BinaryTree` truncated 
at `maxRecursion`;
    - an empty root message still fails with `InvalidSchemaException` ("Cannot 
write a schema with an
      empty group").
    
    `ProtoSchemaConverterTest.testEmptyMessageFields` pins the converted 
schema. The full
    parquet-protobuf suite passes (117 tests).
    
    ### Are there any user-facing changes?
    
    Message types that previously could not be written to Parquet at all now 
can; fields of empty
    message types appear as (possibly LIST/MAP-wrapped) `binary` columns and 
read back into the
    original messages with `ProtoParquetReader`. Files with `maxRecursion` 
truncation, previously
    unreadable by `ProtoParquetReader`, now read back as well. No change for 
schemas that were
    previously writable. Error behavior for an empty root message is unchanged.
    
    Closes #2142
---
 parquet-protobuf/README.md                         |  45 ++++
 .../parquet/proto/ProtoMessageConverter.java       |  37 +++
 .../apache/parquet/proto/ProtoSchemaConverter.java |  28 +++
 .../apache/parquet/proto/ProtoWriteSupport.java    |  33 +--
 .../parquet/proto/ProtoEmptyMessageTest.java       | 250 +++++++++++++++++++++
 .../parquet/proto/ProtoSchemaConverterTest.java    |  21 ++
 parquet-protobuf/src/test/resources/Trees.proto    |  12 +
 7 files changed, 411 insertions(+), 15 deletions(-)

diff --git a/parquet-protobuf/README.md b/parquet-protobuf/README.md
index c073b93bc..34981065f 100644
--- a/parquet-protobuf/README.md
+++ b/parquet-protobuf/README.md
@@ -21,3 +21,48 @@ parquet-protobuf
 ================
 
 Protocol Buffer support for Parquet columnar format.
+
+## Message fields stored as proto bytes
+
+Two kinds of message fields cannot be mapped to a Parquet group, so 
`ProtoSchemaConverter`
+terminates them as the **serialized protobuf message** instead:
+
+* **Fields of an empty message type** (`message Stub {}`) &mdash; Parquet 
forbids empty groups.
+  An empty message serializes to zero bytes, so the column is cheap, and field 
presence still
+  round-trips: `null` means the field was unset, an empty value means it was 
set.
+* **Recursive fields beyond `parquet.proto.maxRecursion`** (default 5) &mdash; 
the remaining
+  sub-tree is stored as the serialized message instead of expanding the schema 
forever.
+
+The Parquet type is an unannotated `BINARY` column that keeps the field's own 
repetition (or,
+for repeated fields and map values, sits inside the standard `LIST` / `MAP` 
wrappers when
+`parquet.proto.writeSpecsCompliant` is set):
+
+```
+message Trees.StubBox {
+  optional binary stub = 1;                    // Stub stub = 1;
+  optional group stubs (LIST) = 2 {            // repeated Stub stubs = 2;
+    repeated group list {
+      required binary element;
+    }
+  }
+  optional group stub_map (MAP) = 3 {          // map<string, Stub> stub_map = 
3;
+    repeated group key_value {
+      required binary key (STRING);
+      optional binary value;
+    }
+  }
+}
+```
+
+Readers that do not know about protobuf simply see opaque bytes (all of them 
empty for an
+empty message type). Readers that have the generated message class can parse 
the bytes back into
+the message; `ProtoParquetReader` does this automatically, resolving the class 
from the
+`parquet.proto.class` footer key (or from the class configured for reading). 
The writer also stores
+the message descriptor in the footer under `parquet.proto.descriptor`, which 
tools that do not
+have the generated class can use to interpret the bytes; `ProtoParquetReader` 
itself does not read
+it.
+
+Note that the column type follows the proto schema at write time: if an empty 
message type later
+gains fields, or `parquet.proto.maxRecursion` is changed, new files store the 
field as a group
+where old files store `BINARY`. Tools that merge schemas across such files 
will report a type
+conflict, the same way they do for any other field whose type changed.
diff --git 
a/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoMessageConverter.java
 
b/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoMessageConverter.java
index ff9e4ca33..0da0dfca9 100644
--- 
a/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoMessageConverter.java
+++ 
b/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoMessageConverter.java
@@ -36,6 +36,7 @@ import com.google.protobuf.DoubleValue;
 import com.google.protobuf.FloatValue;
 import com.google.protobuf.Int32Value;
 import com.google.protobuf.Int64Value;
+import com.google.protobuf.InvalidProtocolBufferException;
 import com.google.protobuf.Message;
 import com.google.protobuf.StringValue;
 import com.google.protobuf.UInt32Value;
@@ -347,6 +348,9 @@ class ProtoMessageConverter extends GroupConverter {
           if (messageType.equals(BytesValue.getDescriptor())) {
             return new ProtoBytesValueConverter(pvc);
           }
+          // Otherwise the column holds the serialized message itself: 
ProtoSchemaConverter stores
+          // fields of empty message types and recursion beyond maxRecursion 
as proto bytes.
+          return new ProtoBinaryMessageConverter(pvc, parentBuilder, 
fieldDescriptor);
         }
         Message.Builder subBuilder = 
parentBuilder.newBuilderForField(fieldDescriptor);
         return new ProtoMessageConverter(conf, pvc, subBuilder, 
parquetType.asGroupType(), extraMetadata);
@@ -495,6 +499,39 @@ class ProtoMessageConverter extends GroupConverter {
     }
   }
 
+  /**
+   * Reads a message field that {@link ProtoSchemaConverter} stored as the 
serialized proto bytes
+   * (a field of an empty message type, or recursion truncated at 
maxRecursion) back into the
+   * message.
+   */
+  static final class ProtoBinaryMessageConverter extends PrimitiveConverter {
+
+    private final ParentValueContainer parent;
+    private final Message.Builder parentBuilder;
+    private final Descriptors.FieldDescriptor fieldDescriptor;
+
+    ProtoBinaryMessageConverter(
+        ParentValueContainer parent,
+        Message.Builder parentBuilder,
+        Descriptors.FieldDescriptor fieldDescriptor) {
+      this.parent = parent;
+      this.parentBuilder = parentBuilder;
+      this.fieldDescriptor = fieldDescriptor;
+    }
+
+    @Override
+    public void addBinary(Binary binary) {
+      Message.Builder builder = 
parentBuilder.newBuilderForField(fieldDescriptor);
+      try {
+        builder.mergeFrom(ByteString.copyFrom(binary.toByteBuffer()));
+      } catch (InvalidProtocolBufferException e) {
+        throw new ParquetDecodingException(
+            "Cannot parse field " + fieldDescriptor.getFullName() + " from its 
serialized proto bytes", e);
+      }
+      parent.add(builder.build());
+    }
+  }
+
   static final class ProtoBinaryConverter extends PrimitiveConverter {
 
     final ParentValueContainer parent;
diff --git 
a/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoSchemaConverter.java
 
b/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoSchemaConverter.java
index ff27b263e..dc0c97b7f 100644
--- 
a/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoSchemaConverter.java
+++ 
b/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoSchemaConverter.java
@@ -70,6 +70,22 @@ import org.slf4j.LoggerFactory;
 
 /**
  * Converts a Protocol Buffer Descriptor into a Parquet schema.
+ * <p>
+ * Message fields normally become Parquet groups. Two kinds of message fields 
cannot, and are
+ * instead terminated as an unannotated {@code BINARY} column holding the 
serialized proto message
+ * (keeping the field's repetition, or sitting inside the usual LIST/MAP 
wrappers):
+ * <ul>
+ *   <li>fields of an <em>empty</em> message type, because Parquet forbids 
empty groups; the value is
+ *       zero bytes when the field is set and {@code null} when it is not, so 
presence still
+ *       round-trips;</li>
+ *   <li>recursive fields nested deeper than {@code maxRecursion}.</li>
+ * </ul>
+ * Readers unaware of protobuf see opaque bytes. {@code ProtoParquetReader} 
parses them back into the
+ * message using the generated class it resolves from the {@code 
parquet.proto.class} footer key (or
+ * the class configured for reading). Since the column type follows
+ * the proto schema at write time, an empty message type that later gains 
fields (or a changed
+ * {@code maxRecursion}) produces a group where older files hold {@code 
BINARY}, like any other
+ * field whose type changed. See the parquet-protobuf README for details.
  */
 public class ProtoSchemaConverter {
 
@@ -327,6 +343,18 @@ public class ProtoSchemaConverter {
       return addMapField(descriptor, builder, seen, depth);
     }
 
+    // Parquet forbids empty groups, so a field of an empty message type is 
terminated as proto
+    // bytes (zero bytes when the message is set - presence still 
round-trips), preserving the
+    // field's repetition so the write path (Array/Repeated/MapWriter) still 
matches the schema.
+    if (descriptor.getMessageType().getFields().isEmpty()) {
+      if (descriptor.isRepeated() && parquetSpecsCompliant) {
+        // LIST-wrap the truncated bytes the same way any repeated primitive 
is wrapped
+        return addRepeatedPrimitive(BINARY, null, builder);
+      }
+      // optional, required, or repeated in the old schema style
+      return builder.primitive(BINARY, 
getRepetition(descriptor)).as((LogicalTypeAnnotation) null);
+    }
+
     seen = ImmutableSetMultimap.<String, Integer>builder()
         .putAll(seen)
         .put(typeName, depth)
diff --git 
a/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoWriteSupport.java
 
b/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoWriteSupport.java
index 51e2d7e25..c6109468f 100644
--- 
a/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoWriteSupport.java
+++ 
b/parquet-protobuf/src/main/java/org/apache/parquet/proto/ProtoWriteSupport.java
@@ -356,42 +356,45 @@ public class ProtoWriteSupport<T extends 
MessageOrBuilder> extends WriteSupport<
       }
 
       // This can happen now that recursive schemas get truncated to bytes.  
Write the bytes.
-      if (type.isPrimitive()
-          && type.asPrimitiveType().getPrimitiveTypeName() == 
PrimitiveType.PrimitiveTypeName.BINARY) {
+      // The truncated type keeps the field's shape, so it may sit behind a 
LIST wrapper
+      // (repeated field) or be the value inside a MAP's key_value group.
+      Type contentType = getContentType(type);
+      if (contentType.isPrimitive()
+          && contentType.asPrimitiveType().getPrimitiveTypeName() == 
PrimitiveType.PrimitiveTypeName.BINARY) {
         return new BinaryWriter();
       }
 
-      return new MessageWriter(fieldDescriptor.getMessageType(), 
getGroupType(type));
+      return new MessageWriter(fieldDescriptor.getMessageType(), 
contentType.asGroupType());
     }
 
-    private GroupType getGroupType(Type type) {
+    /** Unwraps the LIST/MAP wrapper groups to the type holding the message 
content itself. */
+    private Type getContentType(Type type) {
+      if (type.isPrimitive()) {
+        return type;
+      }
       LogicalTypeAnnotation logicalTypeAnnotation = 
type.getLogicalTypeAnnotation();
       if (logicalTypeAnnotation == null) {
-        return type.asGroupType();
+        return type;
       }
       return logicalTypeAnnotation
-          .accept(new 
LogicalTypeAnnotation.LogicalTypeAnnotationVisitor<GroupType>() {
+          .accept(new 
LogicalTypeAnnotation.LogicalTypeAnnotationVisitor<Type>() {
             @Override
-            public Optional<GroupType> visit(
-                LogicalTypeAnnotation.ListLogicalTypeAnnotation 
listLogicalType) {
+            public Optional<Type> 
visit(LogicalTypeAnnotation.ListLogicalTypeAnnotation listLogicalType) {
               return ofNullable(type.asGroupType()
                   .getType("list")
                   .asGroupType()
-                  .getType("element")
-                  .asGroupType());
+                  .getType("element"));
             }
 
             @Override
-            public Optional<GroupType> visit(
-                LogicalTypeAnnotation.MapLogicalTypeAnnotation mapLogicalType) 
{
+            public Optional<Type> 
visit(LogicalTypeAnnotation.MapLogicalTypeAnnotation mapLogicalType) {
               return ofNullable(type.asGroupType()
                   .getType("key_value")
                   .asGroupType()
-                  .getType("value")
-                  .asGroupType());
+                  .getType("value"));
             }
           })
-          .orElse(type.asGroupType());
+          .orElse(type);
     }
 
     private MapWriter createMapWriter(FieldDescriptor fieldDescriptor, Type 
type) {
diff --git 
a/parquet-protobuf/src/test/java/org/apache/parquet/proto/ProtoEmptyMessageTest.java
 
b/parquet-protobuf/src/test/java/org/apache/parquet/proto/ProtoEmptyMessageTest.java
new file mode 100644
index 000000000..1d70d9b17
--- /dev/null
+++ 
b/parquet-protobuf/src/test/java/org/apache/parquet/proto/ProtoEmptyMessageTest.java
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.parquet.proto;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.google.protobuf.Message;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.hadoop.ParquetFileReader;
+import org.apache.parquet.hadoop.ParquetReader;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.GroupReadSupport;
+import org.apache.parquet.proto.test.Trees;
+import org.apache.parquet.schema.InvalidSchemaException;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.MessageTypeParser;
+import org.apache.parquet.schema.PrimitiveType;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Fields typed as an EMPTY proto message cannot map to a parquet group 
(parquet forbids empty
+ * groups, so writer construction used to fail with an {@code 
InvalidSchemaException}). They are
+ * now terminated as proto bytes, like recursion beyond maxRecursion, which 
also keeps the field's
+ * presence observable (null vs an empty byte array).
+ * <p>
+ * The written files are read back both with {@link ProtoParquetReader} (which 
parses the bytes
+ * back into the message) and with parquet-hadoop's protobuf-agnostic {@link 
GroupReadSupport},
+ * to show that a reader without protobuf knowledge sees plain, unannotated 
binary columns.
+ */
+public class ProtoEmptyMessageTest {
+
+  private static Path write(boolean specsCompliant, Message... messages) 
throws IOException {
+    Path file = TestUtils.someTemporaryFilePath();
+    Configuration conf = new Configuration();
+    ProtoWriteSupport.setWriteSpecsCompliant(conf, specsCompliant);
+    try (ParquetWriter<Message> writer = 
ProtoParquetWriter.<Message>builder(file)
+        .withMessage(messages[0].getClass())
+        .withConf(conf)
+        .build()) {
+      for (Message message : messages) {
+        writer.write(message);
+      }
+    }
+    return file;
+  }
+
+  /** Reads with parquet-hadoop's generic {@link GroupReadSupport}, which 
knows nothing about protobuf. */
+  private static List<Group> readWithGenericReader(Path file) throws 
IOException {
+    List<Group> rows = new ArrayList<>();
+    try (ParquetReader<Group> reader =
+        ParquetReader.builder(new GroupReadSupport(), file).build()) {
+      for (Group group = reader.read(); group != null; group = reader.read()) {
+        rows.add(group);
+      }
+    }
+    return rows;
+  }
+
+  private static MessageType readFooterSchema(Path file) throws IOException {
+    try (ParquetFileReader reader = ParquetFileReader.open(new 
Configuration(), file)) {
+      return reader.getFileMetaData().getSchema();
+    }
+  }
+
+  @Test
+  public void emptyMessageFieldsAreUnannotatedBinaryColumns() throws Exception 
{
+    // What a reader without protobuf knowledge sees in the footer: plain 
BINARY columns with no
+    // logical type, keeping the field's repetition (or the standard LIST/MAP 
wrappers).
+    MessageType schema = readFooterSchema(write(true, 
Trees.StubBox.getDefaultInstance()));
+
+    assertThat(schema)
+        .isEqualTo(MessageTypeParser.parseMessageType("message Trees.StubBox 
{\n"
+            + "  optional binary stub = 1;\n"
+            + "  optional group stubs (LIST) = 2 {\n"
+            + "    repeated group list {\n"
+            + "      required binary element;\n"
+            + "    }\n"
+            + "  }\n"
+            + "  optional group stub_map (MAP) = 3 {\n"
+            + "    repeated group key_value {\n"
+            + "      required binary key (STRING);\n"
+            + "      optional binary value;\n"
+            + "    }\n"
+            + "  }\n"
+            + "  optional binary name (STRING) = 4;\n"
+            + "}"));
+
+    PrimitiveType stub = schema.getType("stub").asPrimitiveType();
+    
assertThat(stub.getPrimitiveTypeName()).isEqualTo(PrimitiveType.PrimitiveTypeName.BINARY);
+    assertThat(stub.getLogicalTypeAnnotation())
+        .as("no logical type: the bytes are opaque to non-protobuf readers")
+        .isNull();
+
+    PrimitiveType element = schema.getType("stubs", "list", 
"element").asPrimitiveType();
+    
assertThat(element.getPrimitiveTypeName()).isEqualTo(PrimitiveType.PrimitiveTypeName.BINARY);
+    assertThat(element.getLogicalTypeAnnotation()).isNull();
+
+    PrimitiveType value = schema.getType("stub_map", "key_value", 
"value").asPrimitiveType();
+    
assertThat(value.getPrimitiveTypeName()).isEqualTo(PrimitiveType.PrimitiveTypeName.BINARY);
+    assertThat(value.getLogicalTypeAnnotation()).isNull();
+  }
+
+  @Test
+  public void emptyMessageFieldsAreUnannotatedBinaryColumnsOldStyle() throws 
Exception {
+    MessageType schema = readFooterSchema(write(false, 
Trees.StubBox.getDefaultInstance()));
+
+    PrimitiveType stubs = schema.getType("stubs").asPrimitiveType();
+    assertThat(stubs.getRepetition())
+        .as("old style keeps the repeated field itself")
+        .isEqualTo(PrimitiveType.Repetition.REPEATED);
+    
assertThat(stubs.getPrimitiveTypeName()).isEqualTo(PrimitiveType.PrimitiveTypeName.BINARY);
+    assertThat(stubs.getLogicalTypeAnnotation()).isNull();
+  }
+
+  @Test
+  public void emptyMessageFieldsWriteAsBytes() throws Exception {
+    Trees.StubBox box = Trees.StubBox.newBuilder()
+        .setStub(Trees.Stub.getDefaultInstance())
+        .addStubs(Trees.Stub.getDefaultInstance())
+        .addStubs(Trees.Stub.getDefaultInstance())
+        .putStubMap("k", Trees.Stub.getDefaultInstance())
+        .setName("x")
+        .build();
+
+    Group row = readWithGenericReader(write(true, box)).get(0);
+    assertThat(row.getBinary("stub", 0).length())
+        .as("optional empty message present as zero bytes")
+        .isEqualTo(0);
+    assertThat(row.getGroup("stubs", 0).getFieldRepetitionCount("list"))
+        .as("repeated empty messages keep their cardinality")
+        .isEqualTo(2);
+    Group entry = row.getGroup("stub_map", 0).getGroup("key_value", 0);
+    assertThat(entry.getString("key", 0)).as("map keys stay 
typed").isEqualTo("k");
+    assertThat(entry.getBinary("value", 0).length())
+        .as("map value is zero bytes")
+        .isEqualTo(0);
+    assertThat(row.getString("name", 0)).isEqualTo("x");
+  }
+
+  @Test
+  public void emptyMessagePresenceRoundTrips() throws Exception {
+    Trees.StubBox with = Trees.StubBox.newBuilder()
+        .setStub(Trees.Stub.getDefaultInstance())
+        .build();
+    Trees.StubBox without = Trees.StubBox.getDefaultInstance();
+
+    List<Group> rows = readWithGenericReader(write(true, with, without));
+    assertThat(rows.get(0).getFieldRepetitionCount("stub"))
+        .as("set empty message is present")
+        .isEqualTo(1);
+    assertThat(rows.get(1).getFieldRepetitionCount("stub"))
+        .as("unset empty message is null")
+        .isEqualTo(0);
+  }
+
+  @Test
+  public void emptyMessageFieldsWriteAsBytesOldStyle() throws Exception {
+    Trees.StubBox box = Trees.StubBox.newBuilder()
+        .addStubs(Trees.Stub.getDefaultInstance())
+        .addStubs(Trees.Stub.getDefaultInstance())
+        .build();
+
+    Group row = readWithGenericReader(write(false, box)).get(0);
+    assertThat(row.getFieldRepetitionCount("stubs"))
+        .as("repeated empty messages keep their cardinality")
+        .isEqualTo(2);
+  }
+
+  @Test
+  public void emptyMessageFieldsRoundTripThroughProtoReader() throws Exception 
{
+    Trees.StubBox box = Trees.StubBox.newBuilder()
+        .setStub(Trees.Stub.getDefaultInstance())
+        .addStubs(Trees.Stub.getDefaultInstance())
+        .addStubs(Trees.Stub.getDefaultInstance())
+        .putStubMap("k", Trees.Stub.getDefaultInstance())
+        .setName("x")
+        .build();
+    Trees.StubBox without = Trees.StubBox.newBuilder().setName("y").build();
+
+    assertThat(TestUtils.readMessages(write(true, box, without), 
Trees.StubBox.class))
+        .as("ProtoParquetReader parses the proto bytes back, presence 
included")
+        .containsExactly(box, without);
+  }
+
+  @Test
+  public void emptyMessageFieldsRoundTripThroughProtoReaderOldStyle() throws 
Exception {
+    Trees.StubBox box = Trees.StubBox.newBuilder()
+        .setStub(Trees.Stub.getDefaultInstance())
+        .addStubs(Trees.Stub.getDefaultInstance())
+        .addStubs(Trees.Stub.getDefaultInstance())
+        .putStubMap("k", Trees.Stub.getDefaultInstance())
+        .build();
+
+    assertThat(TestUtils.readMessages(write(false, box), Trees.StubBox.class))
+        .containsExactly(box);
+  }
+
+  @Test
+  public void truncatedRecursionRoundTripsThroughProtoReader() throws 
Exception {
+    // the same binary-to-message read path serves recursion truncated at 
maxRecursion
+    Trees.BinaryTree.Builder tree = Trees.BinaryTree.newBuilder();
+    Trees.BinaryTree.Builder cursor = tree;
+    for (int i = 0; i < 6; i++) {
+      cursor.getValueBuilder().setTypeUrl("level-" + i);
+      cursor = cursor.getLeftBuilder();
+    }
+    Path file = TestUtils.someTemporaryFilePath();
+    Configuration conf = new Configuration();
+    ProtoWriteSupport.setWriteSpecsCompliant(conf, true);
+    ProtoSchemaConverter.setMaxRecursion(conf, 2);
+    try (ParquetWriter<Message> writer = 
ProtoParquetWriter.<Message>builder(file)
+        .withMessage(Trees.BinaryTree.class)
+        .withConf(conf)
+        .build()) {
+      writer.write(tree.build());
+    }
+
+    assertThat(TestUtils.readMessages(file, 
Trees.BinaryTree.class)).containsExactly(tree.build());
+  }
+
+  @Test
+  public void emptyRootMessageStillRejected() {
+    // the root message itself cannot be terminated as bytes - there is no 
field to hold them
+    assertThatThrownBy(() -> write(true, Trees.Stub.getDefaultInstance()))
+        .isInstanceOf(InvalidSchemaException.class)
+        .hasMessageContaining("Cannot write a schema with an empty group");
+  }
+}
diff --git 
a/parquet-protobuf/src/test/java/org/apache/parquet/proto/ProtoSchemaConverterTest.java
 
b/parquet-protobuf/src/test/java/org/apache/parquet/proto/ProtoSchemaConverterTest.java
index a4539e339..5c8bc0b7e 100644
--- 
a/parquet-protobuf/src/test/java/org/apache/parquet/proto/ProtoSchemaConverterTest.java
+++ 
b/parquet-protobuf/src/test/java/org/apache/parquet/proto/ProtoSchemaConverterTest.java
@@ -579,6 +579,27 @@ public class ProtoSchemaConverterTest {
         new ProtoSchemaConverter(true, PAR_RECURSION_DEPTH, false));
   }
 
+  @Test
+  public void testEmptyMessageFields() throws Exception {
+    String expectedSchema = JOINER.join(
+        "message Trees.StubBox {",
+        "  optional binary stub = 1;",
+        "  optional group stubs (LIST) = 2 {",
+        "    repeated group list {",
+        "      required binary element;",
+        "    }",
+        "  }",
+        "  optional group stub_map (MAP) = 3 {",
+        "    repeated group key_value {",
+        "      required binary key (STRING);",
+        "      optional binary value;",
+        "    }",
+        "  }",
+        "  optional binary name (STRING) = 4;",
+        "}");
+    testConversion(Trees.StubBox.class, expectedSchema, new 
ProtoSchemaConverter(true, 5, false));
+  }
+
   @Test
   public void testDeepRecursion() {
     // The general idea is to test the fanout of the schema.
diff --git a/parquet-protobuf/src/test/resources/Trees.proto 
b/parquet-protobuf/src/test/resources/Trees.proto
index c62754d6b..342f6b094 100644
--- a/parquet-protobuf/src/test/resources/Trees.proto
+++ b/parquet-protobuf/src/test/resources/Trees.proto
@@ -35,3 +35,15 @@ message WideTree {
     google.protobuf.Any value = 1;
     repeated WideTree children = 2;
 }
+
+// An empty message: parquet cannot represent an empty group, so fields of this
+// type are terminated as proto bytes (like recursion beyond maxRecursion).
+message Stub {
+}
+
+message StubBox {
+    Stub stub = 1;
+    repeated Stub stubs = 2;
+    map<string, Stub> stub_map = 3;
+    string name = 4;
+}

Reply via email to