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