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

jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git


The following commit(s) were added to refs/heads/master by this push:
     new 853ebe3991 Align HOCON quoting with parser rules and add opt-in 
Parquet logical type UUID support
853ebe3991 is described below

commit 853ebe3991279261b6476c4d5a2a95123d281e60
Author: James Bognar <[email protected]>
AuthorDate: Tue Jun 2 09:56:43 2026 -0400

    Align HOCON quoting with parser rules and add opt-in Parquet logical type 
UUID support
---
 .../java/org/apache/juneau/hocon/HoconWriter.java  |   4 +-
 .../juneau/parquet/ParquetParserSession.java       |  94 ++++++++++----
 .../juneau/parquet/ParquetSchemaElement.java       |  28 +++-
 .../apache/juneau/parquet/ParquetSerializer.java   |  31 ++++-
 .../juneau/parquet/ParquetSerializerSession.java   |   2 +-
 .../apache/juneau/hocon/HoconRoundTrip_Test.java   |  28 ++++
 .../apache/juneau/hocon/HoconSerializer_Test.java  |  22 ++++
 .../juneau/parquet/ParquetLogicalType_Test.java    | 143 +++++++++++++++++++++
 juneau-utest/test-run-history.tsv                  |   1 +
 9 files changed, 324 insertions(+), 29 deletions(-)

diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconWriter.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconWriter.java
index 8a815a8855..1cb4eb8bd7 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconWriter.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/hocon/HoconWriter.java
@@ -46,7 +46,9 @@ public class HoconWriter extends SerializerWriter {
        //         BASIC_ISO_DATE timezone offset, e.g. `20240615+0900`).
        //   `$` — first half of `${...}` substitution, and forbidden in 
unquoted strings (also
        //         ClassFormat.BINARY_NAME inner-class delimiter, e.g. 
`java.util.Map$Entry`).
-       private static final AsciiSet QUOTE_VALUE_CHARS = AsciiSet.of(" 
\t\n\r{},:=+$\"'#");
+       //   `^ \ ? ! @ * &` — forbidden in unquoted strings by the HOCON 
tokenizer/spec, so values
+       //         containing them must be quoted for serializer->parser 
round-trip fidelity.
+       private static final AsciiSet QUOTE_VALUE_CHARS = AsciiSet.of(" 
\t\n\r{}[],:=+$\"'#^\\?!@*&");
        private static final AsciiSet ENCODED_CHARS = 
AsciiSet.of("\n\t\b\f\r\"\\");
 
        /** Use newlines instead of commas between members. */
diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetParserSession.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetParserSession.java
index 1c3f96cc31..b24993a2fa 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetParserSession.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetParserSession.java
@@ -162,7 +162,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                                || inner == Character.class || 
Number.class.isAssignableFrom(inner)))
                                elementType = 
ctx.getMarshallingContext().getClassMeta(Map.class);
                }
-               var rows = readAllRows(bytes, meta, elementType, 
meta.schemaRepetition(), meta.rawByteArrayPaths());
+               var rows = readAllRows(bytes, meta, elementType, 
meta.schemaRepetition(), meta.rawByteArrayPaths(), meta.uuidPaths());
                // Unwrap ValueHolder {value: X} when single row has "value" 
key and target expects scalar (not Map/List<Map>)
                boolean targetWantsScalar = !type.isMap()
                        && !(type.isCollection() && type.getElementType() != 
null && type.getElementType().isMap())
@@ -281,7 +281,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
        private static final long MAX_NUM_ROWS = 10_000_000;
        private static final long MAX_NUM_VALUES = 10_000_000;
 
-       private record FileMeta(long numRows, List<RowGroupMeta> rowGroups, 
Map<String, Integer> schemaRepetition, Set<String> rawByteArrayPaths) {}
+       private record FileMeta(long numRows, List<RowGroupMeta> rowGroups, 
Map<String, Integer> schemaRepetition, Set<String> rawByteArrayPaths, 
Set<String> uuidPaths) {}
        private record RowGroupMeta(List<ColumnChunkMeta> columns) {}
        private record ColumnChunkMeta(int type, List<String> pathInSchema, int 
codec, long numValues, long dataPageOffset, long totalCompressedSize) {}
 
@@ -293,8 +293,15 @@ public class ParquetParserSession extends 
InputStreamParserSession {
         * {@code logicalType} discriminant ({@code 
ParquetSchemaElement.writeTo} omits field 7), so the
         * parser uses "{@code TYPE_BYTE_ARRAY} with no {@code convertedType}" 
as the unique signal that a
         * column should be reassembled back into {@code byte[]} instead of 
decoded as UTF-8 text.
+        *
+        * <p>
+        * {@code uuidPaths} carries the set of leaf paths that should be 
decoded as {@link UUID}. The decision
+        * prefers the explicit {@code LogicalType} discriminant (field 10) 
when present — a column is a UUID iff
+        * its logical type is UUID — and falls back to the legacy "{@code 
FIXED_LEN_BYTE_ARRAY} == UUID"
+        * physical-type heuristic when no logical type is recorded (backward 
compatibility with files written
+        * without the opt-in discriminator).
         */
-       private record SchemaReadResult(Map<String, Integer> repetitions, 
Set<String> rawByteArrayPaths) {}
+       private record SchemaReadResult(Map<String, Integer> repetitions, 
Set<String> rawByteArrayPaths, Set<String> uuidPaths) {}
 
        private static FileMeta parseFileMetaData(byte[] footer) throws 
ParseException {
                try {
@@ -304,6 +311,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                        List<RowGroupMeta> rowGroups = null;
                        Map<String, Integer> schemaRepetition = Map.of();
                        Set<String> rawByteArrayPaths = Set.of();
+                       Set<String> uuidPaths = Set.of();
                        ThriftCompactDecoder.FieldHeader fh;
                        while (!(fh = dec.readFieldHeader()).isStop) {
                                switch (fh.fieldId) {
@@ -312,6 +320,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                                                var sr = readSchema(dec);
                                                schemaRepetition = 
sr.repetitions();
                                                rawByteArrayPaths = 
sr.rawByteArrayPaths();
+                                               uuidPaths = sr.uuidPaths();
                                        }
                                        case 3 -> numRows = dec.readI64();
                                        case 4 -> rowGroups = 
readRowGroups(dec);
@@ -319,7 +328,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                                }
                        }
                        dec.readStructEnd();
-                       return new FileMeta(numRows, rowGroups != null ? 
rowGroups : List.of(), schemaRepetition, rawByteArrayPaths);
+                       return new FileMeta(numRows, rowGroups != null ? 
rowGroups : List.of(), schemaRepetition, rawByteArrayPaths, uuidPaths);
                } catch (IOException e) {
                        throw new ParseException(e);
                }
@@ -332,6 +341,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                var pathStack = new ArrayList<SchemaStackFrame>();
                var result = new LinkedHashMap<String, Integer>();
                var rawByteArrayPaths = new java.util.LinkedHashSet<String>();
+               var uuidPaths = new java.util.LinkedHashSet<String>();
                for (int i = 0; i < lh.size; i++) {
                        dec.readStructBegin();
                        Integer type = null;
@@ -339,6 +349,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                        String name = null;
                        Integer numChildren = null;
                        Integer convertedType = null;
+                       Integer logicalType = null;
                        ThriftCompactDecoder.FieldHeader fh;
                        while (!(fh = dec.readFieldHeader()).isStop) {
                                switch (fh.fieldId) {
@@ -348,6 +359,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                                        case 4 -> name = dec.readString();
                                        case 5 -> numChildren = dec.readI32();
                                        case 6 -> convertedType = dec.readI32();
+                                       case 10 -> logicalType = 
readLogicalTypeUnion(dec);
                                        default -> dec.skipField(fh.type);
                                }
                        }
@@ -373,9 +385,17 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                                // (TYPE_FIXED_LEN_BYTE_ARRAY uniquely 
identifies UUIDs) at a different physical type.
                                if (type == TYPE_BYTE_ARRAY && convertedType == 
null)
                                        rawByteArrayPaths.add(path);
+                               // Prefer the explicit LogicalType discriminant 
(field 10) when present; otherwise fall back to
+                               // the legacy "FIXED_LEN_BYTE_ARRAY == UUID" 
physical-type heuristic for backward compatibility
+                               // with files written without the opt-in 
discriminator (work item 134).
+                               if (type == TYPE_FIXED_LEN_BYTE_ARRAY) {
+                                       var readAsUuid = (logicalType != null) 
? (logicalType == LOGICAL_TYPE_UUID) : true;
+                                       if (readAsUuid)
+                                               uuidPaths.add(path);
+                               }
                                if (parquetDebug())
                                        parquetDebugLog("readSchema leaf: 
path=" + path + " name=" + name + " rep=" + rep
-                                               + " convertedType=" + 
convertedType + " pathStack=" + pathStack);
+                                               + " convertedType=" + 
convertedType + " logicalType=" + logicalType + " pathStack=" + pathStack);
                                while (!pathStack.isEmpty()) {
                                        var top = 
pathStack.get(pathStack.size() - 1);
                                        var newRemaining = top.remaining - 1;
@@ -389,8 +409,29 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                        }
                }
                if (parquetDebug())
-                       parquetDebugLog("readSchema result: " + result + " 
rawByteArrayPaths=" + rawByteArrayPaths);
-               return new SchemaReadResult(result, rawByteArrayPaths);
+                       parquetDebugLog("readSchema result: " + result + " 
rawByteArrayPaths=" + rawByteArrayPaths + " uuidPaths=" + uuidPaths);
+               return new SchemaReadResult(result, rawByteArrayPaths, 
uuidPaths);
+       }
+
+       /**
+        * Reads a Parquet {@code LogicalType} union (parquet.thrift field 10) 
and returns the discriminant of
+        * the set union member (e.g. {@link 
ParquetSchemaElement#LOGICAL_TYPE_UUID} for {@code UUIDType}).
+        *
+        * <p>
+        * A Thrift union is encoded like a struct with exactly one field set; 
the field id is the union
+        * discriminant. Returns <jk>null</jk> if the union is empty (no member 
set).
+        */
+       private static Integer readLogicalTypeUnion(ThriftCompactDecoder dec) 
throws IOException {
+               dec.readStructBegin();
+               Integer discriminant = null;
+               ThriftCompactDecoder.FieldHeader fh;
+               while (!(fh = dec.readFieldHeader()).isStop) {
+                       if (discriminant == null)
+                               discriminant = fh.fieldId;
+                       dec.skipField(fh.type);
+               }
+               dec.readStructEnd();
+               return discriminant;
        }
 
        private static void skipList(ThriftCompactDecoder dec) throws 
IOException {
@@ -474,7 +515,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                return list;
        }
 
-       private List<?> readAllRows(byte[] fileBytes, FileMeta meta, 
ClassMeta<?> elementType, Map<String, Integer> schemaRepetition, Set<String> 
rawByteArrayPaths) throws ParseException {
+       private List<?> readAllRows(byte[] fileBytes, FileMeta meta, 
ClassMeta<?> elementType, Map<String, Integer> schemaRepetition, Set<String> 
rawByteArrayPaths, Set<String> uuidPaths) throws ParseException {
                if (meta.numRows() == 0)
                        return List.of();
                var firstGroup = meta.rowGroups().isEmpty() ? null : 
meta.rowGroups().get(0);
@@ -491,7 +532,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                        else if (isMapKeyValueColumnPath(path))
                                values = readMapKeyValueColumnChunk(fileBytes, 
cc, numRows, trim);
                        else
-                               values = readColumnChunk(fileBytes, cc, 
numRows, schemaRepetition, rawByteArrayPaths, trim);
+                               values = readColumnChunk(fileBytes, cc, 
numRows, schemaRepetition, rawByteArrayPaths, uuidPaths, trim);
                        columnData.put(path, values);
                }
                if (parquetDebug()) {
@@ -867,7 +908,7 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                return result;
        }
 
-       private static List<Object> readColumnChunk(byte[] fileBytes, 
ColumnChunkMeta cc, int numRows, Map<String, Integer> schemaRepetition, 
Set<String> rawByteArrayPaths, boolean trimStrings) throws ParseException {
+       private static List<Object> readColumnChunk(byte[] fileBytes, 
ColumnChunkMeta cc, int numRows, Map<String, Integer> schemaRepetition, 
Set<String> rawByteArrayPaths, Set<String> uuidPaths, boolean trimStrings) 
throws ParseException {
                try {
                        int off = (int)cc.dataPageOffset();
                        var bais = new ByteArrayInputStream(fileBytes, off, 
fileBytes.length - off);
@@ -910,15 +951,17 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                        boolean isPrimitiveType = cc.type() == TYPE_INT32 || 
cc.type() == TYPE_INT64 || cc.type() == TYPE_FLOAT || cc.type() == TYPE_DOUBLE 
|| cc.type() == TYPE_BOOLEAN;
                        int maxDefLevel = (rep == REQUIRED || (isUnderListRoot 
&& isPrimitiveType)) ? 0 : 1;
                        boolean isRawByteArrayColumn = 
rawByteArrayPaths.contains(path);
+                       boolean isUuidColumn = uuidPaths.contains(path);
                        if (parquetDebug())
                                parquetDebugLog("readColumnChunk: path=" + path 
+ " pathInSchema=" + cc.pathInSchema()
                                + " rep=" + rep + " (REQ=" + REQUIRED + ") 
maxDefLevel=" + maxDefLevel + " type=" + cc.type()
-                               + " isPrimitive=" + isPrimitiveType + " 
isRawByteArrayColumn=" + isRawByteArrayColumn);
+                               + " isPrimitive=" + isPrimitiveType + " 
isRawByteArrayColumn=" + isRawByteArrayColumn
+                               + " isUuidColumn=" + isUuidColumn);
                        int valuesToRead = (int)Math.min(cc.numValues(), 
numRows);
                        var reader = new ParquetColumnReader(decompressed, 
valuesToRead, maxDefLevel);
                        var values = new ArrayList<>();
                        while (reader.hasNext()) {
-                               values.add(readValue(reader, cc.type(), 
trimStrings, isRawByteArrayColumn));
+                               values.add(readValue(reader, cc.type(), 
trimStrings, isRawByteArrayColumn, isUuidColumn));
                        }
                        if (parquetDebug())
                                parquetDebugLog("readColumnChunk values: path=" 
+ path + " values=" + values);
@@ -929,10 +972,12 @@ public class ParquetParserSession extends 
InputStreamParserSession {
        }
 
        private static Object readValue(ParquetColumnReader reader, int type, 
boolean trimStrings) throws IOException {
-               return readValue(reader, type, trimStrings, false);
+               // List/map element columns: no per-path discriminator is 
threaded, so keep the legacy
+               // "FIXED_LEN_BYTE_ARRAY == UUID" assumption 
(isUuidColumn=true) to preserve existing round trips.
+               return readValue(reader, type, trimStrings, false, true);
        }
 
-       private static Object readValue(ParquetColumnReader reader, int type, 
boolean trimStrings, boolean isRawByteArrayColumn) throws IOException {
+       private static Object readValue(ParquetColumnReader reader, int type, 
boolean trimStrings, boolean isRawByteArrayColumn, boolean isUuidColumn) throws 
IOException {
                reader.advance();
                if (reader.isNull())
                        return null;
@@ -954,16 +999,21 @@ public class ParquetParserSession extends 
InputStreamParserSession {
                                var s = reader.readByteArrayAsString();
                                yield trimStrings && s != null ? s.trim() : s;
                        }
-                       // TYPE_FIXED_LEN_BYTE_ARRAY is only emitted by 
ParquetSchemaBuilder for UUID columns
-                       // (TYPE_FIXED_LEN_BYTE_ARRAY(16) / LOGICAL_TYPE_UUID). 
 The Parquet file footer does
-                       // not carry the logical-type discriminant 
(ParquetSchemaElement.writeTo deliberately
-                       // omits it), so we use the physical type itself as the 
UUID signal — safe because no
-                       // other writer path produces this physical type.  
Without this conversion the parser
-                       // would surface a raw byte[16] into the row JsonMap 
and the framework's generic
-                       // String/byte[]→UUID coercion path can't reassemble it 
back to a UUID.
+                       // TYPE_FIXED_LEN_BYTE_ARRAY is emitted by 
ParquetSchemaBuilder for UUID columns
+                       // (TYPE_FIXED_LEN_BYTE_ARRAY(16) / LOGICAL_TYPE_UUID). 
 Routing prefers the explicit
+                       // LogicalType discriminant when the writer emitted it 
(opt-in mode), and otherwise falls
+                       // back to the legacy "FLBA == UUID" physical-type 
assumption — both resolve to isUuidColumn
+                       // upstream (see readSchema).  Without the UUID 
conversion the parser would surface a raw
+                       // byte[16] into the row JsonMap and the framework's 
generic String/byte[]→UUID coercion
+                       // path can't reassemble it back to a UUID.
                        case TYPE_FIXED_LEN_BYTE_ARRAY -> {
                                var bytes = reader.readFixedLenByteArray(16);
-                               yield bytes == null ? null : 
uuidFromFixedLenBytes(bytes);
+                               if (bytes == null)
+                                       yield null;
+                               // A FLBA column with an explicit non-UUID 
logical type is surfaced as raw bytes rather than
+                               // misread as a UUID. Juneau's own writer never 
emits such a column today, so this is a
+                               // forward-compat guard for future FLBA logical 
types. // HTT
+                               yield isUuidColumn ? 
uuidFromFixedLenBytes(bytes) : bytes;
                        }
                        default -> {
                                var s = reader.readByteArrayAsString();
diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSchemaElement.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSchemaElement.java
index 5054c034c2..5c648e42b7 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSchemaElement.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSchemaElement.java
@@ -159,10 +159,20 @@ public final class ParquetSchemaElement {
        /**
         * Writes this schema element to the Thrift Compact Protocol encoder.
         *
+        * <p>
+        * When {@code emitLogicalTypes} is <jk>false</jk> (the default) only 
the physical type and the legacy
+        * {@code convertedType} discriminant are written, matching the 
historical wire shape. When
+        * <jk>true</jk>, an explicit {@code LogicalType} union (parquet.thrift 
field 10) is additionally
+        * emitted for ambiguous physical representations. The first-pass scope 
is UUID only: the one type whose
+        * round-trip currently relies on the {@code FIXED_LEN_BYTE_ARRAY} 
physical-type signal alone. The
+        * {@code convertedType} field is still written alongside the union so 
that readers that consume only
+        * the legacy field (including older Juneau versions) continue to work 
unchanged.
+        *
         * @param enc The encoder.
+        * @param emitLogicalTypes If <jk>true</jk>, additionally emit the 
{@code LogicalType} union for UUID columns.
         * @throws IOException If an I/O error occurs.
         */
-       public void writeTo(ThriftCompactEncoder enc) throws IOException {
+       public void writeTo(ThriftCompactEncoder enc, boolean emitLogicalTypes) 
throws IOException {
                enc.writeStructBegin();
                if (type != null) {
                        enc.writeFieldBegin(ThriftCompactEncoder.I32, 1);
@@ -194,9 +204,19 @@ public final class ParquetSchemaElement {
                        enc.writeFieldBegin(ThriftCompactEncoder.I32, 8);
                        enc.writeI32(precision);
                }
-               // ConvertedType is sufficient for the current implementation.
-               // LogicalType encoding can be added back once the nested 
Thrift union
-               // layout is verified against the Parquet spec.
+               // Opt-in only, UUID-first scope: emit the LogicalType union 
(field 10) so readers can route
+               // FIXED_LEN_BYTE_ARRAY deterministically instead of relying on 
the "FLBA == UUID" physical-type
+               // assumption. The union member for UUID is field 14 (UUIDType, 
an empty struct). Other logical
+               // types (STRING/TIMESTAMP/DECIMAL) are intentionally not 
emitted yet — they round-trip fine via
+               // convertedType + physical type today.
+               if (emitLogicalTypes && logicalType != null && logicalType == 
LOGICAL_TYPE_UUID) {
+                       enc.writeFieldBegin(ThriftCompactEncoder.STRUCT, 10);
+                       enc.writeStructBegin();
+                       enc.writeFieldBegin(ThriftCompactEncoder.STRUCT, 
LOGICAL_TYPE_UUID);
+                       enc.writeStructBegin();
+                       enc.writeStructEnd();
+                       enc.writeStructEnd();
+               }
                enc.writeStructEnd();
        }
 
diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSerializer.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSerializer.java
index 2d6cb524a8..fc4c6fde16 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSerializer.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSerializer.java
@@ -62,6 +62,7 @@ public class ParquetSerializer extends OutputStreamSerializer 
implements Parquet
                private int pageSize = 1024 * 1024;
                private boolean addBeanTypesParquet = false;
                private boolean writeDatesAsTimestamp = true;
+               private boolean emitLogicalTypes = false;
                private ParquetCycleHandling cycleHandling = 
ParquetCycleHandling.NULL;
                private String nullKeyString;
 
@@ -71,6 +72,7 @@ public class ParquetSerializer extends OutputStreamSerializer 
implements Parquet
                protected Builder() {
                        produces("application/vnd.apache.parquet");
                        accept("application/vnd.apache.parquet");
+                       emitLogicalTypes = 
env("ParquetSerializer.emitLogicalTypes", false);
                        nullKeyString = env("ParquetSerializer.nullKeyString", 
"<NULL>");
                }
 
@@ -86,6 +88,7 @@ public class ParquetSerializer extends OutputStreamSerializer 
implements Parquet
                        pageSize = copyFrom.pageSize;
                        addBeanTypesParquet = copyFrom.addBeanTypesParquet;
                        writeDatesAsTimestamp = copyFrom.writeDatesAsTimestamp;
+                       emitLogicalTypes = copyFrom.emitLogicalTypes;
                        cycleHandling = copyFrom.cycleHandling;
                        nullKeyString = copyFrom.nullKeyString;
                }
@@ -102,6 +105,7 @@ public class ParquetSerializer extends 
OutputStreamSerializer implements Parquet
                        pageSize = copyFrom.pageSize;
                        addBeanTypesParquet = copyFrom.addBeanTypesParquet;
                        writeDatesAsTimestamp = copyFrom.writeDatesAsTimestamp;
+                       emitLogicalTypes = copyFrom.emitLogicalTypes;
                        cycleHandling = copyFrom.cycleHandling;
                        nullKeyString = copyFrom.nullKeyString;
                }
@@ -161,6 +165,29 @@ public class ParquetSerializer extends 
OutputStreamSerializer implements Parquet
                        return this;
                }
 
+               /**
+                * Emits explicit logical-type discriminator metadata for 
ambiguous physical representations.
+                *
+                * <p>
+                * Disabled by default, which preserves the current Parquet 
wire shape (physical type plus
+                * {@code convertedType} only). Modeled on JSON's optional 
<js>_type</js> discriminator: when enabled,
+                * the serializer additionally writes the Parquet 
<c>LogicalType</c> union into the file footer for
+                * columns whose physical encoding is otherwise ambiguous.
+                *
+                * <p>
+                * First-pass scope is UUID only — the one type whose 
round-trip relies on the
+                * {@code FIXED_LEN_BYTE_ARRAY} physical-type signal. The 
legacy {@code convertedType} field is still
+                * emitted alongside the union, so readers that consume only 
the legacy field (including older Juneau
+                * versions) continue to work unchanged, while modern readers 
can route on {@code logicalType}.
+                *
+                * @param value Whether to emit logical-type discriminator 
metadata.
+                * @return This object.
+                */
+               public Builder emitLogicalTypes(boolean value) {
+                       emitLogicalTypes = value;
+                       return this;
+               }
+
                /**
                 * Sets how to handle cyclic references during serialization.
                 *
@@ -201,7 +228,7 @@ public class ParquetSerializer extends 
OutputStreamSerializer implements Parquet
 
                @Override
                public HashKey hashKey() {
-                       return HashKey.of(super.hashKey(), compressionCodec, 
rowGroupSize, pageSize, addBeanTypesParquet, writeDatesAsTimestamp, 
cycleHandling, nullKeyString);
+                       return HashKey.of(super.hashKey(), compressionCodec, 
rowGroupSize, pageSize, addBeanTypesParquet, writeDatesAsTimestamp, 
emitLogicalTypes, cycleHandling, nullKeyString);
                }
        }
 
@@ -219,6 +246,7 @@ public class ParquetSerializer extends 
OutputStreamSerializer implements Parquet
        final int pageSize;
        final boolean addBeanTypesParquet;
        final boolean writeDatesAsTimestamp;
+       final boolean emitLogicalTypes;
        final ParquetCycleHandling cycleHandling;
        final String nullKeyString;
 
@@ -237,6 +265,7 @@ public class ParquetSerializer extends 
OutputStreamSerializer implements Parquet
                pageSize = builder.pageSize;
                addBeanTypesParquet = builder.addBeanTypesParquet;
                writeDatesAsTimestamp = builder.writeDatesAsTimestamp;
+               emitLogicalTypes = builder.emitLogicalTypes;
                cycleHandling = builder.cycleHandling;
                nullKeyString = builder.nullKeyString;
        }
diff --git 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSerializerSession.java
 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSerializerSession.java
index 7508a44361..09a98214d8 100644
--- 
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSerializerSession.java
+++ 
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/parquet/ParquetSerializerSession.java
@@ -815,7 +815,7 @@ public class ParquetSerializerSession extends 
OutputStreamSerializerSession {
                        enc.writeFieldBegin(ThriftCompactEncoder.LIST, 2);
                        enc.writeListBegin(ThriftCompactEncoder.STRUCT, 
schema.size());
                        for (var e : schema)
-                               e.writeTo(enc);
+                               e.writeTo(enc, ctx.emitLogicalTypes);
                        enc.writeFieldBegin(ThriftCompactEncoder.I64, 3);
                        enc.writeI64(numRows);
                        enc.writeFieldBegin(ThriftCompactEncoder.LIST, 4);
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconRoundTrip_Test.java 
b/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconRoundTrip_Test.java
index 016fe6ce60..6544c707fe 100644
--- 
a/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconRoundTrip_Test.java
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconRoundTrip_Test.java
@@ -162,4 +162,32 @@ class HoconRoundTrip_Test extends TestBase {
                var b = (Map<String, Object>) HoconParser.DEFAULT.parse(hocon, 
Map.class, String.class, Object.class);
                assertBean(b, "name,age,active,tags,address{city},empty", 
"Bob,25,true,[x,y],{NYC},");
        }
+
+       @Test
+       void c13_parserForbiddenCharsRoundTrip() throws Exception {
+               var a = new LinkedHashMap<String, Object>();
+               a.put("caret", "a^b");
+               a.put("slash", "a\\b");
+               a.put("question", "a?b");
+               a.put("bang", "a!b");
+               a.put("at", "a@b");
+               a.put("star", "a*b");
+               a.put("amp", "a&b");
+
+               var hocon = HoconSerializer.DEFAULT.serialize(a);
+               assertTrue(hocon.contains("caret = \"a^b\""));
+               assertTrue(hocon.contains("slash = \"a\\\\b\""));
+               assertTrue(hocon.contains("question = \"a?b\""));
+               assertTrue(hocon.contains("bang = \"a!b\""));
+               assertTrue(hocon.contains("at = \"a@b\""));
+               assertTrue(hocon.contains("star = \"a*b\""));
+               assertTrue(hocon.contains("amp = \"a&b\""));
+
+               var b = (Map<String, Object>) HoconParser.DEFAULT.parse(hocon, 
Map.class, String.class, Object.class);
+               assertBean(
+                       b,
+                       "caret,slash,question,bang,at,star,amp",
+                       "a^b,a\\b,a?b,a!b,a@b,a*b,a&b"
+               );
+       }
 }
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconSerializer_Test.java 
b/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconSerializer_Test.java
index 63d5fa5687..ccc2f3a1a5 100644
--- 
a/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconSerializer_Test.java
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/hocon/HoconSerializer_Test.java
@@ -18,8 +18,10 @@ package org.apache.juneau.hocon;
 
 import static org.junit.jupiter.api.Assertions.*;
 
+import java.lang.reflect.*;
 import java.util.*;
 
+import org.apache.juneau.commons.lang.*;
 import org.junit.jupiter.api.*;
 
 /**
@@ -226,4 +228,24 @@ class HoconSerializer_Test {
                assertTrue(hocon.contains("outer") && hocon.contains("inner") 
&& hocon.contains("value"));
                assertTrue(hocon.contains("\n"));
        }
+
+       @Test
+       void a20_quoteValueCharsCoverUnquotedForbidden() throws Exception {
+               var forbidden = 
(String)getPrivateStaticField(HoconTokenizer.class, "UNQUOTED_FORBIDDEN");
+               var quoteValueChars = 
(AsciiSet)getPrivateStaticField(HoconWriter.class, "QUOTE_VALUE_CHARS");
+
+               for (var i = 0; i < forbidden.length(); i++) {
+                       var c = forbidden.charAt(i);
+                       assertTrue(
+                               quoteValueChars.contains(c),
+                               () -> "Missing serializer quote coverage for 
parser-forbidden character: '" + c + "'"
+                       );
+               }
+       }
+
+       private static Object getPrivateStaticField(Class<?> c, String 
fieldName) throws Exception {
+               var f = c.getDeclaredField(fieldName);
+               f.setAccessible(true);
+               return f.get(null);
+       }
 }
diff --git 
a/juneau-utest/src/test/java/org/apache/juneau/parquet/ParquetLogicalType_Test.java
 
b/juneau-utest/src/test/java/org/apache/juneau/parquet/ParquetLogicalType_Test.java
new file mode 100644
index 0000000000..743412ec68
--- /dev/null
+++ 
b/juneau-utest/src/test/java/org/apache/juneau/parquet/ParquetLogicalType_Test.java
@@ -0,0 +1,143 @@
+/*
+ * 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.juneau.parquet;
+
+import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.junit.bct.BctAssertions.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.*;
+
+import org.apache.juneau.*;
+import org.junit.jupiter.api.*;
+
+/**
+ * Tests for the opt-in, default-OFF Parquet footer logical-type discriminator 
(work item 134).
+ *
+ * <p>
+ * The discriminator is modeled on JSON's optional {@code _type} behavior: by 
default the writer emits
+ * only the physical type plus {@code convertedType} (the historical wire 
shape), and the UUID round-trip
+ * relies on the {@code FIXED_LEN_BYTE_ARRAY} physical-type signal. When
+ * {@link ParquetSerializer.Builder#emitLogicalTypes(boolean) 
emitLogicalTypes(true)} is set, the writer
+ * additionally emits the Parquet {@code LogicalType} union (UUID-first scope) 
into the footer, and the
+ * parser prefers that discriminant while remaining backward compatible when 
it is absent.
+ */
+@SuppressWarnings({
+       "unchecked" // Parser returns raw types; explicit casts required for 
typed assertions
+})
+class ParquetLogicalType_Test extends TestBase {
+
+       private static final UUID UUID_A = 
UUID.fromString("12345678-1234-5678-1234-567812345678");
+       private static final UUID UUID_B = 
UUID.fromString("00000000-0000-0000-0000-000000000001");
+
+       /** Bean with a UUID property — the one type whose round-trip relies on 
a physical-type signal. */
+       public static class UuidBean {
+               public String name;
+               public UUID id;
+       }
+
+       private static UuidBean uuidBean(String name, UUID id) {
+               var b = new UuidBean();
+               b.name = name;
+               b.id = id;
+               return b;
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // A. UUID round trips in both modes
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test
+       void a01_uuidRoundTrip_defaultMode() throws Exception {
+               var a = uuidBean("alice", UUID_A);
+               var bytes = ParquetSerializer.DEFAULT.serialize(a);
+               var parsed = (List<UuidBean>) 
ParquetParser.DEFAULT.parse(bytes, List.class, UuidBean.class);
+               assertBeans(parsed, "name,id", "alice," + UUID_A);
+       }
+
+       @Test
+       void a02_uuidRoundTrip_emitLogicalTypes() throws Exception {
+               var ser = 
ParquetSerializer.create().emitLogicalTypes(true).build();
+               var a = uuidBean("alice", UUID_A);
+               var bytes = ser.serialize(a);
+               var parsed = (List<UuidBean>) 
ParquetParser.DEFAULT.parse(bytes, List.class, UuidBean.class);
+               assertBeans(parsed, "name,id", "alice," + UUID_A);
+       }
+
+       @Test
+       void a03_uuidListRoundTrip_emitLogicalTypes() throws Exception {
+               var ser = 
ParquetSerializer.create().emitLogicalTypes(true).build();
+               var bytes = ser.serialize(list(uuidBean("alice", UUID_A), 
uuidBean("bob", UUID_B)));
+               var parsed = (List<UuidBean>) 
ParquetParser.DEFAULT.parse(bytes, List.class, UuidBean.class);
+               assertBeans(parsed, "name,id", "alice," + UUID_A, "bob," + 
UUID_B);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // B. Two-way compatibility
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test
+       void b01_forwardCompat_preChangeFileReadByNewParser() throws Exception {
+               // Default-mode output is byte-identical to the pre-change 
writer (the knob is purely additive),
+               // so a DEFAULT-serialized file is an equivalent, drift-free 
stand-in for a committed golden fixture.
+               var a = uuidBean("alice", UUID_A);
+               var preChangeBytes = ParquetSerializer.DEFAULT.serialize(a);
+               var parsed = (List<UuidBean>) 
ParquetParser.DEFAULT.parse(preChangeBytes, List.class, UuidBean.class);
+               assertBeans(parsed, "name,id", "alice," + UUID_A);
+       }
+
+       @Test
+       void b02_backwardCompat_newFileSameResultAsDefault() throws Exception {
+               // A file carrying the additive LogicalType union must 
round-trip to the same value as the
+               // default (union-free) file — adding the union does not change 
the parsed result.
+               var a = uuidBean("alice", UUID_A);
+               var defaultBytes = ParquetSerializer.DEFAULT.serialize(a);
+               var emitBytes = 
ParquetSerializer.create().emitLogicalTypes(true).build().serialize(a);
+               var fromDefault = (List<UuidBean>) 
ParquetParser.DEFAULT.parse(defaultBytes, List.class, UuidBean.class);
+               var fromEmit = (List<UuidBean>) 
ParquetParser.DEFAULT.parse(emitBytes, List.class, UuidBean.class);
+               assertBeans(fromDefault, "name,id", "alice," + UUID_A);
+               assertBeans(fromEmit, "name,id", "alice," + UUID_A);
+               assertEquals(fromDefault.get(0).id, fromEmit.get(0).id);
+       }
+
+       // 
-----------------------------------------------------------------------------------------------------------------
+       // C. Wire-shape guarantees
+       // 
-----------------------------------------------------------------------------------------------------------------
+
+       @Test
+       void c01_emitAddsBytesAndIsDeterministic() throws Exception {
+               var a = uuidBean("alice", UUID_A);
+               var defaultBytes1 = ParquetSerializer.DEFAULT.serialize(a);
+               var defaultBytes2 = ParquetSerializer.DEFAULT.serialize(a);
+               var emitBytes = 
ParquetSerializer.create().emitLogicalTypes(true).build().serialize(a);
+               // Default output is deterministic (drift-free fixture 
guarantee).
+               assertArrayEquals(defaultBytes1, defaultBytes2);
+               // Enabling the discriminator changes the wire shape (the 
LogicalType union is emitted).
+               assertTrue(emitBytes.length > defaultBytes1.length, "Expected 
emit-on footer to be larger than default footer");
+       }
+
+       @Test
+       void c02_nonUuidColumnsUnaffectedByKnob() throws Exception {
+               // First-pass scope is UUID only: a bean with no UUID column 
must produce identical bytes in both modes.
+               var a = new ParquetSerializer_Test.SimpleBean();
+               a.name = "alice";
+               a.age = 30;
+               var defaultBytes = ParquetSerializer.DEFAULT.serialize(a);
+               var emitBytes = 
ParquetSerializer.create().emitLogicalTypes(true).build().serialize(a);
+               assertArrayEquals(defaultBytes, emitBytes);
+       }
+}
diff --git a/juneau-utest/test-run-history.tsv 
b/juneau-utest/test-run-history.tsv
index 082d8bb205..bb5fc09fd3 100644
--- a/juneau-utest/test-run-history.tsv
+++ b/juneau-utest/test-run-history.tsv
@@ -64,3 +64,4 @@ timestamp     git_sha branch  tests_run       failures        
errors  skipped surefire_sec    wall_sec
 2026-06-01T19:34:19Z   f8486683b13c    master  126197  0       0       26      
185
 2026-06-02T11:01:51Z   9363babe275f    master  126197  0       0       26      
181
 2026-06-02T12:03:07Z   46da4ebd52bd    master  126197  0       0       26      
175
+2026-06-02T13:55:41Z   1b6abc9889bc    master  126206  0       0       26      
179


Reply via email to