001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.marshall.parquet; 018 019import static org.apache.juneau.commons.utils.Shorts.*; 020import static org.apache.juneau.marshall.parquet.ParquetSchemaElement.*; 021 022import java.io.*; 023import java.lang.reflect.*; 024import java.nio.charset.*; 025import java.time.*; 026import java.time.temporal.*; 027import java.util.*; 028 029import org.apache.juneau.commons.bean.*; 030import org.apache.juneau.marshall.*; 031import org.apache.juneau.marshall.serializer.*; 032import org.apache.juneau.marshall.stream.*; 033 034/** 035 * Session for {@link ParquetSerializer}. 036 */ 037@SuppressWarnings({ 038 "java:S107", // Recursive list flattening requires path/index/def/rep context; refactor would obscure 039 "java:S110", // Deep chain comes from the Serializer/OutputStreamSerializerSession session base classes 040 "java:S1192", // Duplicated "value" is ValueHolder/Optional schema key; constant would obscure 041 "java:S3776", // collectBeans/flattenListValuesImpl/writeValue branch on many value shapes (Map, List, array, Optional, swaps, logical types) 042 "java:S6541", // Brain Method: Parquet bean collection and list flattening are inherently branchy 043 "java:S9391", // Row/column loops stay imperative because their bodies call methods that throw checked SerializeException/IOException 044 "resource" // Output streams managed by calling code 045}) 046public class ParquetSerializerSession extends OutputStreamSerializerSession implements RecordWritable, ArrayRecordWritable { 047 048 private static final byte[] MAGIC = "PAR1".getBytes(StandardCharsets.UTF_8); 049 050 /** 051 * Builder for serializer sessions. 052 */ 053 public static class Builder extends OutputStreamSerializerSession.Builder<Builder> { 054 055 private final ParquetSerializer ctx; 056 057 protected Builder(ParquetSerializer ctx) { 058 super(reqnn("ctx", ctx)); 059 this.ctx = ctx; 060 } 061 062 @Override 063 public ParquetSerializerSession build() { 064 return new ParquetSerializerSession(this); 065 } 066 } 067 068 /** 069 * Creates a new session builder. 070 * 071 * @param ctx The serializer context. 072 * <br>Cannot be <jk>null</jk>. 073 * @return A new builder. 074 */ 075 public static Builder create(ParquetSerializer ctx) { 076 return new Builder(reqnn("ctx", ctx)); 077 } 078 079 private final ParquetSerializer ctx; 080 081 protected ParquetSerializerSession(Builder builder) { 082 super(builder); 083 ctx = builder.ctx; 084 } 085 086 /** 087 * Opens a whole-value push generator targeting Parquet output, bound to this live session. 088 * {@link RecordWriter#write(Object) write(Object)} delegates to 089 * {@link SerializerSession#write(Object, Object)}. 090 * 091 * @param output The output. 092 * @return A new {@link RecordWriter}. 093 * @throws IOException If a problem occurred opening the underlying output. 094 */ 095 @Override /* RecordWritable */ 096 public RecordWriter writeRecords(Object output) throws IOException { 097 return RecordAdapter.writer(this, output); 098 } 099 100 /** 101 * Buffered array-element {@link RecordWriter} for Parquet, bound to this live session. Buffers 102 * each {@code write(...)} and emits the whole list on {@code close()}. 103 * 104 * @param output The output. 105 * @return A new {@link RecordWriter}. 106 * @throws IOException If a problem occurred opening the underlying output. 107 */ 108 @Override /* ArrayRecordWritable */ 109 public RecordWriter writeArrayRecords(Object output) throws IOException { 110 return RecordAdapter.arrayWriter(this, output); 111 } 112 113 /** 114 * The Parquet record writer is buffered/{@link RecordAdapter}-backed, not O(1) streaming. 115 * 116 * @return Always <jk>false</jk>. 117 */ 118 @Override /* RecordWritable */ 119 public boolean isRecordStreaming() { return false; } 120 121 /** 122 * The Parquet array-record writer is buffered/{@link RecordAdapter}-backed, not O(1) streaming. 123 * 124 * @return Always <jk>false</jk>. 125 */ 126 @Override /* ArrayRecordWritable */ 127 public boolean isArrayRecordStreaming() { return false; } 128 129 @Override /* OutputStreamSerializerSession */ 130 public boolean hasNativeBytes() { 131 // Parquet's column writer has no native byte-array primitive type — it stores byte[] as a raw 132 // BYTE_ARRAY column (at BinaryFormat.NOT_SET) or as a UTF-8 string column 133 // (at any other BinaryFormat, after the BinarySwap fires and emits the configured text wire form). 134 return false; 135 } 136 137 @Override 138 protected void doWrite(SerializerPipe pipe, Object o) throws IOException, SerializeException { 139 var out = pipe.getOutputStream(); 140 var rows = collectBeans(o); 141 if (rows.isEmpty()) { 142 writeEmptyFile(out); 143 return; 144 } 145 var first = rows.get(0); 146 List<ParquetSchemaElement> schema; 147 // collectBeans() always returns rows whose first element is a BeanMap (bean / collection / array / 148 // single-value roots, all BeanMap-wrapped) or a Map (string-keyed and non-string-keyed map roots), 149 // so those are the only two cases. 150 if (first instanceof BeanMap<?> first2) { 151 var elementType = getClassMetaForObject(first2.getBean()); 152 // For collection root: use element type for flat row schema (root.name, root.age). 153 // Each collection element becomes one Parquet row. Avoids list column format issues. 154 schema = new ParquetSchemaBuilder(ctx.getMarshallingContext(), ctx.writeDatesAsTimestamp, ctx.cycleHandling, ctx.maxRecursionDepth, ctx.nativeLogicalTypes).buildSchema(elementType, first); 155 } else { 156 var mapFirst = (Map<?,?>) first; 157 if (!(((Map<?,?>) o).keySet().iterator().next() instanceof String)) { 158 schema = new ParquetSchemaBuilder(ctx.getMarshallingContext(), ctx.writeDatesAsTimestamp, ctx.cycleHandling, ctx.maxRecursionDepth, ctx.nativeLogicalTypes) 159 .buildSchemaForKeyValuePairs(mapFirst.get("key"), mapFirst.get("value")); 160 } else { 161 schema = new ParquetSchemaBuilder(ctx.getMarshallingContext(), ctx.writeDatesAsTimestamp, ctx.cycleHandling, ctx.maxRecursionDepth, ctx.nativeLogicalTypes).buildSchemaFromMap(mapFirst); 162 } 163 } 164 var leafColumns = ParquetSchemaBuilder.getLeafColumns(schema); 165 writeMagic(out); 166 long dataStart = 4; 167 var rowGroups = new ArrayList<RowGroupMeta>(); 168 long fileOffset = dataStart; 169 // Multi-row-group write (GAP-2 round-trip): split rows into batches of rowsPerRowGroup and emit one 170 // row group per batch. Defaults keep the historical single-row-group output (the cap is huge), but a 171 // small rowGroupSize produces multiple groups for interop/round-trip coverage. 172 int rowsPerRowGroup = rowsPerRowGroup(leafColumns.size()); 173 for (int start = 0; start < rows.size(); start += rowsPerRowGroup) { 174 var batch = rows.subList(start, Math.min(start + rowsPerRowGroup, rows.size())); 175 long groupStart = fileOffset; 176 var columnChunks = new ArrayList<ColumnChunkMeta>(); 177 for (var col : leafColumns) { 178 var chunk = writeColumnChunk(out, batch, col, fileOffset); 179 columnChunks.add(chunk); 180 fileOffset += chunk.totalCompressedSize; 181 } 182 long totalByteSize = 0; 183 long totalCompressedSize = 0; 184 for (var c : columnChunks) { 185 totalByteSize += c.totalUncompressedSize; 186 totalCompressedSize += c.totalCompressedSize; 187 } 188 rowGroups.add(new RowGroupMeta(columnChunks, totalByteSize, batch.size(), groupStart, totalCompressedSize)); 189 } 190 var footerBytes = writeFileMetaData(schema, rows.size(), rowGroups); 191 out.write(footerBytes); 192 writeLe4(out, footerBytes.length); 193 out.write(MAGIC); 194 } 195 196 /** 197 * Rows per row group, derived from the {@code rowGroupSize} byte budget via a coarse per-row estimate. 198 * 199 * <p> 200 * Real writers flush a group once its accumulated byte size crosses the budget; Juneau's column-oriented 201 * writer materializes whole columns at once, so we instead translate the byte budget into a row count 202 * using a nominal {@code BYTES_PER_ROW_ESTIMATE} per leaf column. With the default 128 MB budget this 203 * yields an effectively unbounded count (single group, unchanged output); a small {@code rowGroupSize} 204 * (e.g. set in a test) produces several groups. 205 */ 206 private int rowsPerRowGroup(int leafColumnCount) { 207 long perRow = (long) Math.max(1, leafColumnCount) * BYTES_PER_ROW_ESTIMATE; 208 long n = Math.max(1, ctx.rowGroupSize / perRow); 209 return (int) Math.min(n, Integer.MAX_VALUE); 210 } 211 212 private static final int BYTES_PER_ROW_ESTIMATE = 32; 213 214 private void writeEmptyFile(OutputStream out) throws IOException { 215 writeMagic(out); 216 var schema = List.of( 217 new ParquetSchemaElement("root", null, null, null, 0, null, null, null, null, null)); 218 var footerBytes = writeFileMetaData(schema, 0L, List.of()); 219 out.write(footerBytes); 220 writeLe4(out, footerBytes.length); 221 out.write(MAGIC); 222 } 223 224 private static void writeMagic(OutputStream out) throws IOException { 225 out.write(MAGIC); 226 } 227 228 private static void writeLe4(OutputStream out, int v) throws IOException { 229 out.write(v & 0xFF); 230 out.write((v >> 8) & 0xFF); 231 out.write((v >> 16) & 0xFF); 232 out.write((v >> 24) & 0xFF); 233 } 234 235 private List<?> collectBeans(Object o) throws SerializeException { 236 if (o == null) 237 return List.of(); 238 // Unwrap Optional at root so we serialize the inner value, not Optional as a bean 239 if (o instanceof Optional<?> o2) { 240 var inner = o2.orElse(null); 241 if (inner == null) 242 return List.of(); 243 return collectBeans(inner); 244 } 245 var sType = getExpectedRootType(o); 246 if (sType.isMap()) { 247 var m = (Map<?,?>) o; 248 if (m.isEmpty()) 249 return List.of(); 250 var firstKey = m.keySet().iterator().next(); 251 if (firstKey instanceof String || firstKey == null) { 252 // Apply POJO swaps to map values so types like A (with swap to JsonMap) use their swapped schema/encoding. 253 // Replace null keys with the null-key sentinel so they survive as a named column in the schema. 254 var swappedMap = m(); 255 for (var e : m.entrySet()) { 256 var key = e.getKey() == null ? ctx.nullKeyString : trim(e.getKey()); 257 var val = e.getValue(); 258 if (val != null) 259 val = applySwap(val, getClassMetaForObject(val)); 260 swappedMap.put(key, val); 261 } 262 return List.of(swappedMap); 263 } 264 var result = new ArrayList<Map<String,Object>>(m.size()); 265 for (var e : m.entrySet()) { 266 var row = new LinkedHashMap<String,Object>(); 267 row.put("key", e.getKey()); 268 row.put("value", e.getValue()); 269 result.add(row); 270 } 271 return result; 272 } 273 if (sType.isCollection() || sType.isArray()) { 274 Collection<?> list = sType.isArray() ? toList(sType.inner(), o) : (Collection<?>)o; 275 if (cn(list).startsWith("java.util.ImmutableCollections")) 276 list = new ArrayList<>(list); 277 var result = new ArrayList<BeanMap<?>>(list.size()); 278 for (var e : list) { 279 if (e == null) { 280 result.add(toBeanMap(createWrapperBean(null))); 281 continue; 282 } 283 var swapped = applySwap(e, getClassMetaForObject(e)); 284 var swappedType = getClassMetaForObject(swapped); 285 var origType = getClassMetaForObject(e); 286 Object toUse; 287 if (swappedType.isBean()) 288 toUse = swapped; 289 else if (origType.isBean()) 290 toUse = e; 291 else 292 toUse = swapped; 293 var useType = getClassMetaForObject(toUse); 294 if (useType.isBean()) 295 result.add(toBeanMap(toUse)); 296 else 297 result.add(toBeanMap(createWrapperBean(toUse))); 298 } 299 return result; 300 } 301 if (sType.isStreamable()) { 302 return collectBeans(toListFromStreamable(o, sType)); 303 } 304 var swapped = applySwap(o, sType); 305 var aType = getClassMetaForObject(swapped); 306 if (aType.isBean()) 307 return List.of(toBeanMap(swapped)); 308 return List.of(toBeanMap(createWrapperBean(swapped))); 309 } 310 311 /** Simple bean for wrapping non-bean values (Map is not convertible to BeanMap). */ 312 @SuppressWarnings({ 313 "java:S1104" // Public field required for Parquet schema 314 }) 315 public static class ValueHolder { 316 /** The wrapped value. */ 317 public Object value; 318 } 319 320 private static Object createWrapperBean(Object value) throws SerializeException { 321 var h = new ValueHolder(); 322 h.value = value; 323 return h; 324 } 325 326 private Object applySwap(Object value, ClassMeta<?> type) throws SerializeException { 327 var swap = type.getSwap(this); 328 if (nn(swap)) 329 return swap(swap, value); 330 return value; 331 } 332 333 /** 334 * Applies any class-level swap registered via {@link org.apache.juneau.marshall.swap.spi.DefaultSwaps} 335 * (or context-registered swaps) to a leaf value just before it is written to a Parquet column. 336 * 337 * <p> 338 * Necessary because bean-property values pass through {@link BeanMap#get(Object)}, which only 339 * applies the per-property {@code readTransform} installed by 340 * {@code MarshalledPropertyPostProcessor}. At {@code NOT_SET} / unconfigured formats no 341 * per-property swap is installed for {@code Class<?>} (and other types whose default swap lives 342 * in {@code DefaultSwaps}); without this hook the raw value would fall through to 343 * {@link String#valueOf(Object)} and produce wires like {@code "class java.lang.String"}. 344 * 345 * <p> 346 * Mirrors the {@code aType.getSwap(session)} layer applied by every other serializer 347 * ({@link org.apache.juneau.marshall.json.JsonSerializerSession}, 348 * {@link org.apache.juneau.marshall.msgpack.MsgPackSerializerSession}, etc.) in their {@code 349 * writeAnything} dispatcher. No-op for values whose runtime type has no registered swap. 350 */ 351 private Object applyDefaultSwap(Object value) throws SerializeException { 352 var cm = getClassMetaForObject(value); 353 var swap = cm.getSwap(this); 354 if (nn(swap)) 355 return swap(swap, value); 356 return value; 357 } 358 359 private ColumnChunkMeta writeColumnChunk(OutputStream out, List<?> rows, ParquetSchemaElement col, long chunkStart) throws IOException, SerializeException { 360 var path = rowRelativePath(col.path); 361 if (path.contains(".list.element")) 362 return writeListColumnChunk(out, rows, col, path, chunkStart); 363 if (isMapKeyValueColumnPath(path)) 364 return writeMapColumnChunk(out, rows, col, path, chunkStart); 365 // Definition-level handling for non-list/non-map columns. 366 // A leaf nested under N OPTIONAL groups needs a max def level of N (GAP-14) so the reader can tell 367 // a null intermediate group apart from a present group with a null leaf. Single-segment columns 368 // (maxDef==1) use the original 1-bit scheme unchanged. 369 int maxDef = path.split("\\.").length; 370 int numRows = rows.size(); 371 372 // Multi-page write (GAP-1 round-trip): split the column's rows into page batches of rowsPerPage. 373 // Defaults keep a single page (huge cap); a small pageSize produces several data pages in one chunk, 374 // which the page-loop reader concatenates. 375 int rowsPerPage = rowsPerPage(); 376 var pathParts = List.of(col.path.split("\\.")); 377 long totalUncompressed = 0; 378 long totalStored = 0; 379 int firstHeaderSize = 0; 380 boolean firstPage = true; 381 for (int start = 0; start < numRows; start += rowsPerPage) { 382 var pageRows = rows.subList(start, Math.min(start + rowsPerPage, numRows)); 383 byte[] pageData = encodeColumnPage(pageRows, col, path, maxDef); 384 int uncompressedSize = pageData.length; 385 byte[] compressedData = ctx.compressionCodec.compress(pageData); 386 int compressedSize = compressedData.length; 387 var pageHeader = ParquetColumnWriter.createPageHeader(pageRows.size(), uncompressedSize, compressedSize); 388 out.write(pageHeader); 389 out.write(compressedData); 390 totalUncompressed += uncompressedSize; 391 totalStored += compressedSize + (long) pageHeader.length; 392 if (firstPage) { 393 firstHeaderSize = pageHeader.length; 394 firstPage = false; 395 } 396 } 397 return new ColumnChunkMeta(col.path, pathParts, col.type, numRows, totalUncompressed, totalStored, chunkStart, firstHeaderSize); 398 } 399 400 private int rowsPerPage() { 401 long n = Math.max(1, ctx.pageSize / BYTES_PER_ROW_ESTIMATE); 402 return (int) Math.min(n, Integer.MAX_VALUE); 403 } 404 405 /** Encodes one data page (def levels + PLAIN values) for a batch of rows of a non-list/non-map column. */ 406 private byte[] encodeColumnPage(List<?> rows, ParquetSchemaElement col, String path, int maxDef) throws IOException, SerializeException { 407 var writer = new ParquetColumnWriter(col); 408 byte[] defLevelBytes; 409 if (maxDef >= 2) { 410 var defLevels = new RleBitPackingEncoder(defRepBitWidth(maxDef)); 411 for (var row : rows) { 412 var dv = computeDefValue(row, path, maxDef); 413 defLevels.writeInt(dv.def()); 414 if (dv.def() == maxDef) 415 writeValue(writer, col, dv.value()); 416 } 417 defLevelBytes = defLevels.toByteArrayWithLength(); 418 } else { 419 var values = extractColumnValues(rows, path); 420 // Root key_value format uses root.key/root.value - convert keys to stored strings (ISO8601, etc.) 421 boolean isRootKeyColumn = "root.key".equals(col.path); 422 if (isRootKeyColumn) { 423 var converted = new ArrayList<>(values.size()); 424 for (var val : values) { 425 converted.add(val == null ? null : mapKeyToStoredString(unwrapOptional(val))); 426 } 427 values = converted; 428 } 429 var defLevels = new RleBitPackingEncoder(1); 430 for (var i = 0; i < values.size(); i++) { 431 var v = unwrapOptional(values.get(i)); 432 defLevels.writeInt(v == null ? 0 : 1); 433 if (v != null) 434 writeValue(writer, col, v); 435 } 436 defLevelBytes = defLevels.toByteArrayWithLength(); 437 } 438 byte[] valueBytes = writer.finalizePage(); 439 byte[] pageData = new byte[defLevelBytes.length + valueBytes.length]; 440 System.arraycopy(defLevelBytes, 0, pageData, 0, defLevelBytes.length); 441 System.arraycopy(valueBytes, 0, pageData, defLevelBytes.length, valueBytes.length); 442 return pageData; 443 } 444 445 private record DefValue(int def, Object value) {} 446 447 /** 448 * Walks {@code path} from {@code row}, returning the definition level (count of leading present OPTIONAL 449 * nodes) and the leaf value. Used by multi-level OPTIONAL columns (GAP-14): {@code def == maxDef} means 450 * the leaf is present; {@code def == maxDef-1} means the deepest group is present but the leaf is null; 451 * {@code def < maxDef-1} means an intermediate group is null at level {@code def}. 452 */ 453 private DefValue computeDefValue(Object row, String path, int maxDef) throws SerializeException { 454 var parts = path.split("\\."); 455 var seen = Collections.newSetFromMap(new IdentityHashMap<Object,Boolean>()); 456 seen.add(row); 457 seen.add(((BeanMap<?>) row).getBean()); 458 Object current = row; 459 int def = 0; 460 for (var i = 0; i < parts.length; i++) { 461 var part = parts[i]; 462 if (current instanceof Optional<?> current2) 463 current = current2.orElse(null); 464 else if (current instanceof BeanMap<?> current2) 465 current = current2.get(part); 466 else 467 current = toBeanMap(current).get(part); 468 if (current != null && seen.contains(current)) 469 current = handleCycle(path); 470 if (current == null) 471 return new DefValue(def, null); 472 seen.add(current); 473 def = i + 1; 474 } 475 return new DefValue(maxDef, unwrapOptional(current)); 476 } 477 478 /** Returns true if path is a map key_value leaf (e.g. f9.key_value.key or f9.key_value.value). */ 479 private static boolean isMapKeyValueColumnPath(String path) { 480 return path.endsWith(".key_value.key") || path.endsWith(".key_value.value"); 481 } 482 483 /** For map key_value path like "f9.key_value.key", returns the map property name ("f9"). */ 484 private static String mapPropertyPath(String mapKeyValuePath) { 485 return mapKeyValuePath.substring(0, mapKeyValuePath.indexOf(".key_value.")); 486 } 487 488 /** Path relative to the row (bean) for extraction. All schema leaf paths start with "root.". */ 489 private static String rowRelativePath(String fullPath) { 490 return fullPath.substring("root.".length()); 491 } 492 493 private static int listDepth(String path) { 494 int depth = 0; 495 for (int idx = 0; (idx = path.indexOf(".list.element", idx)) >= 0; idx += 13) 496 depth++; 497 return depth; 498 } 499 500 private static int maxDefLevelForListPath(String path) { 501 return 3 * listDepth(path); 502 } 503 504 private static int defRepBitWidth(int maxLevel) { 505 return 32 - Integer.numberOfLeadingZeros(maxLevel); 506 } 507 508 private ColumnChunkMeta writeListColumnChunk(OutputStream out, List<?> rows, ParquetSchemaElement col, String path, long chunkStart) throws IOException, SerializeException { 509 var flattened = extractFlattenedListValues(rows, path); 510 int maxDef = maxDefLevelForListPath(path); 511 int maxRep = listDepth(path); 512 int defBitWidth = defRepBitWidth(maxDef); 513 int repBitWidth = defRepBitWidth(maxRep); 514 var defLevels = new RleBitPackingEncoder(defBitWidth); 515 var repLevels = new RleBitPackingEncoder(repBitWidth); 516 var writer = new ParquetColumnWriter(col); 517 for (var e : flattened) { 518 repLevels.writeInt(e.repLevel); 519 defLevels.writeInt(e.defLevel); 520 var v = unwrapOptional(e.value); 521 if (e.defLevel >= maxDef) 522 writeValue(writer, col, v); 523 } 524 byte[] valueBytes = writer.finalizePage(); 525 byte[] defLevelBytes = defLevels.toByteArrayWithLength(); 526 byte[] repLevelBytes = repLevels.toByteArrayWithLength(); 527 byte[] pageData = new byte[repLevelBytes.length + defLevelBytes.length + valueBytes.length]; 528 int off = 0; 529 System.arraycopy(repLevelBytes, 0, pageData, off, repLevelBytes.length); 530 off += repLevelBytes.length; 531 System.arraycopy(defLevelBytes, 0, pageData, off, defLevelBytes.length); 532 off += defLevelBytes.length; 533 System.arraycopy(valueBytes, 0, pageData, off, valueBytes.length); 534 int uncompressedSize = pageData.length; 535 byte[] compressedData = ctx.compressionCodec.compress(pageData); 536 int compressedSize = compressedData.length; 537 var pageHeader = ParquetColumnWriter.createPageHeader(flattened.size(), uncompressedSize, compressedSize); 538 out.write(pageHeader); 539 int headerSize = pageHeader.length; 540 out.write(compressedData); 541 var pathParts = List.of(col.path.split("\\.")); 542 return new ColumnChunkMeta(col.path, pathParts, col.type, flattened.size(), uncompressedSize, compressedSize + (long) headerSize, chunkStart, headerSize); 543 } 544 545 private ColumnChunkMeta writeMapColumnChunk(OutputStream out, List<?> rows, ParquetSchemaElement col, String path, long chunkStart) throws IOException, SerializeException { 546 boolean isKeyColumn = path.endsWith(".key_value.key"); 547 var flattened = extractFlattenedMapValues(rows, path, isKeyColumn); 548 int defBitWidth = defRepBitWidth(MAP_MAX_DEF); 549 int repBitWidth = defRepBitWidth(MAP_MAX_REP); 550 var defLevels = new RleBitPackingEncoder(defBitWidth); 551 var repLevels = new RleBitPackingEncoder(repBitWidth); 552 var writer = new ParquetColumnWriter(col); 553 for (var e : flattened) { 554 repLevels.writeInt(e.repLevel); 555 defLevels.writeInt(e.defLevel); 556 var v = unwrapOptional(e.value); 557 if (e.defLevel >= MAP_MAX_DEF) 558 writeValue(writer, col, v); 559 } 560 byte[] valueBytes = writer.finalizePage(); 561 byte[] defLevelBytes = defLevels.toByteArrayWithLength(); 562 byte[] repLevelBytes = repLevels.toByteArrayWithLength(); 563 byte[] pageData = new byte[repLevelBytes.length + defLevelBytes.length + valueBytes.length]; 564 int off = 0; 565 System.arraycopy(repLevelBytes, 0, pageData, off, repLevelBytes.length); 566 off += repLevelBytes.length; 567 System.arraycopy(defLevelBytes, 0, pageData, off, defLevelBytes.length); 568 off += defLevelBytes.length; 569 System.arraycopy(valueBytes, 0, pageData, off, valueBytes.length); 570 int uncompressedSize = pageData.length; 571 byte[] compressedData = ctx.compressionCodec.compress(pageData); 572 int compressedSize = compressedData.length; 573 var pageHeader = ParquetColumnWriter.createPageHeader(flattened.size(), uncompressedSize, compressedSize); 574 out.write(pageHeader); 575 int headerSize = pageHeader.length; 576 out.write(compressedData); 577 var pathParts = List.of(col.path.split("\\.")); 578 return new ColumnChunkMeta(col.path, pathParts, col.type, flattened.size(), uncompressedSize, compressedSize + (long) headerSize, chunkStart, headerSize); 579 } 580 581 private record FlattenedEntry(Object value, int defLevel, int repLevel) {} 582 583 /** Converts a map key to the string stored in Parquet (UTF8). Handles null, Date, Enum like other formats. */ 584 private Object mapKeyToStoredString(Object key) throws SerializeException { 585 if (key == null) 586 return ctx.nullKeyString; 587 if (key instanceof Date key2) { 588 var cm = getClassMetaForObject(key2); 589 return writeDate(key2, cm); 590 } 591 if (key instanceof Calendar) { 592 var cm = getClassMetaForObject(key); 593 return writeCalendar(key, cm); 594 } 595 if (key instanceof TemporalAccessor key2) { 596 var cm = getClassMetaForObject(key2); 597 return writeTemporal(key2, cm); 598 } 599 if (key instanceof Enum<?> key2) 600 return ctx.getMarshallingContext().getEnumFormat().format(key2); 601 return trim(String.valueOf(generalize(key, getClassMetaForObject(key)))); 602 } 603 604 private List<FlattenedEntry> extractFlattenedListValues(List<?> rows, String path) throws SerializeException { 605 var result = new ArrayList<FlattenedEntry>(); 606 int maxDef = maxDefLevelForListPath(path); 607 int depth = listDepth(path); 608 for (var row : rows) { 609 var seen = Collections.newSetFromMap(new IdentityHashMap<Object,Boolean>()); 610 flattenListValues(row, path, 0, true, 0, depth, maxDef, result, seen); 611 } 612 return result; 613 } 614 615 private static final int MAP_MAX_DEF = 2; 616 private static final int MAP_MAX_REP = 1; 617 618 private List<FlattenedEntry> extractFlattenedMapValues(List<?> rows, String path, boolean isKeyColumn) throws SerializeException { 619 var result = new ArrayList<FlattenedEntry>(); 620 var mapProp = mapPropertyPath(path); 621 for (var row : rows) { 622 var map = getMapAtPath(row, mapProp); 623 if (!map.isEmpty()) { 624 int idx = 0; 625 for (var e : map.entrySet()) { 626 int rep = idx == 0 ? 0 : MAP_MAX_REP; 627 Object val = isKeyColumn ? e.getKey() : e.getValue(); 628 val = unwrapOptional(val); 629 if (isKeyColumn) 630 val = mapKeyToStoredString(val); 631 int def = val != null ? MAP_MAX_DEF : 1; 632 result.add(new FlattenedEntry(val, def, rep)); 633 idx++; 634 } 635 } 636 } 637 return result; 638 } 639 640 private Map<?,?> getMapAtPath(Object row, String mapProp) throws SerializeException { 641 var obj = getValueByPath(row, mapProp); 642 if (obj == null) 643 return Collections.emptyMap(); 644 return (Map<?,?>) obj; 645 } 646 647 private void flattenListValues(Object obj, String path, int partIndex, boolean firstInRow, int currentRep, int listDepth, int maxDef, List<FlattenedEntry> out, Set<Object> seen) throws SerializeException { 648 if (obj != null && seen.contains(obj)) { 649 if (ctx.cycleHandling == ParquetCycleHandling.THROW) 650 throw new SerializeException("Cyclic reference at path '%s'. Set cycleHandling(NULL) to serialize as null.", path); 651 out.add(new FlattenedEntry(null, Math.max(0, maxDef - 1), currentRep)); 652 return; 653 } 654 if (obj != null) 655 seen.add(obj); 656 try { 657 flattenListValuesImpl(obj, path, partIndex, firstInRow, currentRep, listDepth, maxDef, out, seen); 658 } finally { 659 if (obj != null) 660 seen.remove(obj); 661 } 662 } 663 664 private void flattenListValuesImpl(Object obj, String path, int partIndex, boolean firstInRow, int currentRep, int listDepth, int maxDef, List<FlattenedEntry> out, Set<Object> seen) throws SerializeException { 665 var parts = path.split("\\."); 666 if (partIndex >= parts.length) { 667 var val = unwrapOptional(obj); 668 out.add(new FlattenedEntry(val, val != null ? maxDef : Math.max(0, maxDef - 1), currentRep)); 669 return; 670 } 671 if (partIndex + 1 < parts.length && "list".equals(parts[partIndex]) && "element".equals(parts[partIndex + 1])) { 672 Collection<?> list = null; 673 if (obj != null) { 674 if (obj instanceof Collection<?> obj2) 675 list = obj2; 676 else if (obj instanceof Object[] obj2) { 677 list = Arrays.asList(obj2); 678 } else if (obj.getClass().isArray()) { 679 // Primitive arrays (boolean[], int[], char[], etc.) — box each element 680 int len = Array.getLength(obj); 681 var al = new ArrayList<>(len); 682 for (int ai = 0; ai < len; ai++) 683 al.add(Array.get(obj, ai)); 684 list = al; 685 } else 686 list = Collections.singletonList(obj); 687 } 688 // Count how many "list" segments have appeared up to and including partIndex. 689 // This gives the true nesting depth regardless of non-list path segments (e.g. "value" for Optional). 690 int listDepthHere = 0; 691 for (int di = 0; di <= partIndex; di++) 692 if ("list".equals(parts[di])) 693 listDepthHere++; 694 // def levels for null/empty: 2*(depth-1) and 2*(depth-1)+1, so depth=1 → 0,1; depth=2 → 2,3. 695 int nullDef = 2 * (listDepthHere - 1); 696 int emptyDef = 2 * (listDepthHere - 1) + 1; 697 if (list == null) { 698 out.add(new FlattenedEntry(null, nullDef, firstInRow ? 0 : currentRep)); 699 return; 700 } 701 if (list.isEmpty()) { 702 out.add(new FlattenedEntry(null, emptyDef, firstInRow ? 0 : currentRep)); 703 return; 704 } 705 int level = partIndex / 2 + 1; 706 // Use currentRep for the first-element rep level so nested lists carry the correct rep from their parent. 707 int repLevel = firstInRow ? 0 : currentRep; 708 int idx = 0; 709 for (var item : list) { 710 int r = idx == 0 ? repLevel : level; 711 var val = unwrapOptional(item); 712 int def = val == null ? Math.max(0, maxDef - 1) : maxDef; 713 if (partIndex + 2 >= parts.length) { 714 out.add(new FlattenedEntry(val, def, r)); 715 } else { 716 flattenListValues(item, path, partIndex + 2, firstInRow, r, listDepth, maxDef, out, seen); 717 } 718 firstInRow = false; 719 idx++; 720 } 721 return; 722 } 723 Object next = obj; 724 if (next != null) { 725 if (next instanceof Optional<?> next2) 726 next = next2.orElse(null); 727 else if (next instanceof BeanMap<?> next2) 728 next = next2.get(parts[partIndex]); 729 else if (next instanceof Map<?,?> next2) 730 next = next2.get(parts[partIndex]); 731 else 732 next = toBeanMap(next).get(parts[partIndex]); 733 } 734 flattenListValues(next, path, partIndex + 1, firstInRow, currentRep, listDepth, maxDef, out, seen); 735 } 736 737 private List<Object> extractColumnValues(List<?> rows, String path) throws SerializeException { 738 var result = new ArrayList<>(rows.size()); 739 for (var row : rows) 740 result.add(getValueByPath(row, path)); 741 return result; 742 } 743 744 private Object getValueByPath(Object row, String path) throws SerializeException { 745 var parts = path.split("\\."); 746 var seen = Collections.newSetFromMap(new IdentityHashMap<Object,Boolean>()); 747 seen.add(row); 748 if (row instanceof BeanMap<?> row2) 749 seen.add(row2.getBean()); 750 Object current = row; 751 for (var part : parts) { 752 if (current == null) 753 return null; 754 if (current instanceof BeanMap<?> current2) 755 current = current2.get(part); 756 else if (current instanceof Map<?,?> current2) 757 current = current2.get(part); 758 else 759 current = toBeanMap(current).get(part); 760 if (current != null && seen.contains(current)) 761 return handleCycle(path); 762 if (current != null) 763 seen.add(current); 764 } 765 return current; 766 } 767 768 private Object handleCycle(String path) throws SerializeException { 769 if (ctx.cycleHandling == ParquetCycleHandling.THROW) 770 throw new SerializeException("Cyclic reference at path '%s'. Set cycleHandling(NULL) to serialize as null.", path); 771 return null; 772 } 773 774 /** Unwraps Optional (including nested Optional<Optional<T>>) to inner value or null. */ 775 private static Object unwrapOptional(Object v) { 776 while (v instanceof Optional<?> v2) { 777 v = v2.orElse(null); 778 } 779 return v; 780 } 781 782 private void writeValue(ParquetColumnWriter w, ParquetSchemaElement col, Object v) throws IOException, SerializeException { 783 // Unwrap handled by callers (writeColumnChunk, writeListColumnChunk) for correct def levels 784 v = unwrapOptional(v); 785 v = applyDefaultSwap(v); 786 switch (col.type) { 787 case TYPE_BOOLEAN: 788 w.writeBoolean(v instanceof Boolean v2 ? v2 : Boolean.parseBoolean(String.valueOf(v))); 789 break; 790 case TYPE_INT32: 791 // Native DATE (GAP-10): days since epoch. 792 if (col.convertedType == CONVERTED_DATE) 793 w.writeInt32((int) toEpochDay(v)); 794 else 795 w.writeInt32(toInt32(v)); 796 break; 797 case TYPE_INT64: 798 // Native logical INT64 encodings (GAP-9/10): DECIMAL unscaled, TIME/TIMESTAMP in micros. 799 if (col.convertedType == CONVERTED_DECIMAL) 800 w.writeInt64(toDecimalUnscaled(v)); 801 else if (col.convertedType == CONVERTED_TIME_MICROS) 802 w.writeInt64(toTimeMicros(v)); 803 else if (col.convertedType == CONVERTED_TIMESTAMP_MICROS) 804 w.writeInt64(toTimestampMicros(v)); 805 else 806 w.writeInt64(toInt64(v)); 807 break; 808 case TYPE_FLOAT: 809 w.writeFloat(v instanceof Number v2 ? v2.floatValue() : Float.parseFloat(String.valueOf(v))); 810 break; 811 case TYPE_DOUBLE: 812 w.writeDouble(v instanceof Number v2 ? v2.doubleValue() : Double.parseDouble(String.valueOf(v))); 813 break; 814 case TYPE_BYTE_ARRAY: 815 w.writeByteArray(toByteArray(v)); 816 break; 817 case TYPE_FIXED_LEN_BYTE_ARRAY: 818 w.writeFixedLenByteArray(toFixedLenByteArray(v)); 819 break; 820 default: 821 throw new SerializeException("Unsupported Parquet physical type: %s", col.type); 822 } 823 } 824 825 private static int toInt32(Object v) { 826 if (v instanceof Number v2) 827 return v2.intValue(); 828 return Integer.parseInt(String.valueOf(v)); 829 } 830 831 private static long toInt64(Object v) throws SerializeException { 832 if (v instanceof Date v2) 833 return v2.getTime(); 834 if (v instanceof Calendar v2) 835 return v2.getTimeInMillis(); 836 if (v instanceof Instant v2) 837 return v2.toEpochMilli(); 838 if (v instanceof LocalDate v2) 839 return v2.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(); 840 if (v instanceof LocalDateTime v2) 841 return v2.atZone(ZoneOffset.UTC).toInstant().toEpochMilli(); 842 if (v instanceof LocalTime v2) 843 return Duration.ofNanos(v2.toNanoOfDay()).toMillis(); 844 if (v instanceof ZonedDateTime v2) 845 return v2.toInstant().toEpochMilli(); 846 if (v instanceof OffsetDateTime v2) 847 return v2.toInstant().toEpochMilli(); 848 if (v instanceof OffsetTime v2) 849 return Duration.ofNanos(v2.toLocalTime().toNanoOfDay()).toMillis(); 850 if (v instanceof Year v2) 851 return v2.atDay(1).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(); 852 if (v instanceof YearMonth v2) 853 return v2.atDay(1).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli(); 854 if (v instanceof TemporalAccessor v2) { 855 try { 856 return Instant.from(v2).toEpochMilli(); 857 } catch (@SuppressWarnings("unused") Exception e) { 858 return Long.parseLong(String.valueOf(v)); 859 } 860 } 861 if (v instanceof Number v2) 862 return v2.longValue(); 863 return Long.parseLong(String.valueOf(v)); 864 } 865 866 /** Native DATE (GAP-10): epoch-day from a date-like temporal. */ 867 private static long toEpochDay(Object v) throws SerializeException { 868 if (v instanceof LocalDate v2) 869 return v2.toEpochDay(); 870 if (v instanceof LocalDateTime v2) 871 return v2.toLocalDate().toEpochDay(); 872 if (v instanceof Instant v2) 873 return v2.atZone(ZoneOffset.UTC).toLocalDate().toEpochDay(); 874 if (v instanceof ZonedDateTime v2) 875 return v2.toLocalDate().toEpochDay(); 876 if (v instanceof OffsetDateTime v2) 877 return v2.toLocalDate().toEpochDay(); 878 if (v instanceof Number v2) 879 return v2.longValue(); 880 return LocalDate.parse(String.valueOf(v)).toEpochDay(); 881 } 882 883 /** Native TIME_MICROS (GAP-10): micros since midnight. */ 884 private static long toTimeMicros(Object v) { 885 if (v instanceof LocalTime v2) 886 return v2.toNanoOfDay() / 1_000L; 887 if (v instanceof OffsetTime v2) 888 return v2.toLocalTime().toNanoOfDay() / 1_000L; 889 if (v instanceof Number v2) 890 return v2.longValue(); 891 return LocalTime.parse(String.valueOf(v)).toNanoOfDay() / 1_000L; 892 } 893 894 /** Native TIMESTAMP_MICROS (GAP-10): micros since epoch, sub-millisecond precise. */ 895 private static long toTimestampMicros(Object v) throws SerializeException { 896 Instant i = toInstant(v); 897 return Math.multiplyExact(i.getEpochSecond(), 1_000_000L) + i.getNano() / 1_000L; 898 } 899 900 private static Instant toInstant(Object v) throws SerializeException { 901 if (v instanceof Instant v2) 902 return v2; 903 if (v instanceof Date v2) 904 return v2.toInstant(); 905 if (v instanceof Calendar v2) 906 return v2.toInstant(); 907 if (v instanceof LocalDateTime v2) 908 return v2.toInstant(ZoneOffset.UTC); 909 if (v instanceof LocalDate v2) 910 return v2.atStartOfDay(ZoneOffset.UTC).toInstant(); 911 if (v instanceof ZonedDateTime v2) 912 return v2.toInstant(); 913 if (v instanceof OffsetDateTime v2) 914 return v2.toInstant(); 915 if (v instanceof TemporalAccessor v2) { 916 try { 917 return Instant.from(v2); 918 } catch (@SuppressWarnings("unused") Exception e) { 919 // fall through to string parse 920 } 921 } 922 try { 923 return Instant.parse(String.valueOf(v)); 924 } catch (@SuppressWarnings("unused") Exception e) { 925 throw new SerializeException("Cannot convert value of type %s to a TIMESTAMP", v.getClass().getName()); 926 } 927 } 928 929 /** Native DECIMAL (GAP-9): unscaled INT64 at the fixed schema scale; over-precise values are rounded. */ 930 private static long toDecimalUnscaled(Object v) throws SerializeException { 931 java.math.BigDecimal bd; 932 if (v instanceof java.math.BigDecimal v2) 933 bd = v2; 934 else if (v instanceof Number v2) 935 bd = new java.math.BigDecimal(v2.toString()); 936 else 937 bd = new java.math.BigDecimal(String.valueOf(v)); 938 // Scale to the fixed schema scale (round half-up), then take the unscaled long. 939 return bd.setScale(ParquetSchemaBuilder.DECIMAL_SCALE, java.math.RoundingMode.HALF_UP).unscaledValue().longValueExact(); 940 } 941 942 private byte[] toByteArray(Object v) throws SerializeException { 943 if (v instanceof byte[] v2) 944 return v2; 945 if (v instanceof String v2) 946 return trim(v2).getBytes(StandardCharsets.UTF_8); 947 if (v instanceof Enum<?> v2) 948 return ctx.getMarshallingContext().getEnumFormat().format(v2).getBytes(StandardCharsets.UTF_8); 949 if (v instanceof Duration v2) 950 return v2.toString().getBytes(StandardCharsets.UTF_8); 951 // BigDecimal and all other types stored as UTF-8 string. 952 return String.valueOf(v).getBytes(StandardCharsets.UTF_8); 953 } 954 955 private static byte[] toFixedLenByteArray(Object v) { 956 if (v instanceof UUID v2) { 957 byte[] b = new byte[16]; 958 var msb = v2.getMostSignificantBits(); 959 var lsb = v2.getLeastSignificantBits(); 960 for (int i = 0; i < 8; i++) 961 b[i] = (byte)((msb >> (56 - i * 8)) & 0xFF); 962 for (int i = 0; i < 8; i++) 963 b[8 + i] = (byte)((lsb >> (56 - i * 8)) & 0xFF); 964 return b; 965 } 966 return new byte[16]; 967 } 968 969 private byte[] writeFileMetaData(List<ParquetSchemaElement> schema, long numRows, List<RowGroupMeta> rowGroups) throws IOException { 970 return ThriftCompactEncoder.encodeToBytes(enc -> { 971 enc.writeStructBegin(); 972 enc.writeFieldBegin(ThriftCompactEncoder.I32, 1); 973 enc.writeI32(1); 974 enc.writeFieldBegin(ThriftCompactEncoder.LIST, 2); 975 enc.writeListBegin(ThriftCompactEncoder.STRUCT, schema.size()); 976 for (var e : schema) 977 e.writeTo(enc, ctx.emitLogicalTypes); 978 enc.writeFieldBegin(ThriftCompactEncoder.I64, 3); 979 enc.writeI64(numRows); 980 enc.writeFieldBegin(ThriftCompactEncoder.LIST, 4); 981 enc.writeListBegin(ThriftCompactEncoder.STRUCT, rowGroups.size()); 982 for (var rg : rowGroups) { 983 enc.writeStructBegin(); 984 enc.writeFieldBegin(ThriftCompactEncoder.LIST, 1); 985 enc.writeListBegin(ThriftCompactEncoder.STRUCT, rg.columns.size()); 986 for (var cc : rg.columns) { 987 enc.writeStructBegin(); 988 enc.writeFieldBegin(ThriftCompactEncoder.I64, 2); 989 enc.writeI64(cc.fileOffset); 990 enc.writeFieldBegin(ThriftCompactEncoder.STRUCT, 3); 991 writeColumnMetaData(enc, cc); 992 enc.writeStructEnd(); 993 } 994 enc.writeFieldBegin(ThriftCompactEncoder.I64, 2); 995 enc.writeI64(rg.totalByteSize); 996 enc.writeFieldBegin(ThriftCompactEncoder.I64, 3); 997 enc.writeI64(rg.numRows); 998 enc.writeFieldBegin(ThriftCompactEncoder.I64, 5); 999 enc.writeI64(rg.fileOffset); 1000 enc.writeFieldBegin(ThriftCompactEncoder.I64, 6); 1001 enc.writeI64(rg.totalCompressedSize); 1002 enc.writeStructEnd(); 1003 } 1004 enc.writeFieldBegin(ThriftCompactEncoder.BINARY, 6); 1005 enc.writeString("Apache Juneau"); 1006 enc.writeStructEnd(); 1007 }); 1008 } 1009 1010 private void writeColumnMetaData(ThriftCompactEncoder enc, ColumnChunkMeta cc) throws IOException { 1011 enc.writeStructBegin(); 1012 enc.writeFieldBegin(ThriftCompactEncoder.I32, 1); 1013 enc.writeI32(cc.parquetType); 1014 enc.writeFieldBegin(ThriftCompactEncoder.LIST, 2); 1015 enc.writeListBegin(ThriftCompactEncoder.I32, 2); 1016 enc.writeI32(0); 1017 enc.writeI32(3); 1018 enc.writeFieldBegin(ThriftCompactEncoder.LIST, 3); 1019 enc.writeListBegin(ThriftCompactEncoder.BINARY, cc.pathInSchema.size()); 1020 for (var p : cc.pathInSchema) 1021 enc.writeString(p); 1022 enc.writeFieldBegin(ThriftCompactEncoder.I32, 4); 1023 enc.writeI32(ctx.compressionCodec.thriftValue); 1024 enc.writeFieldBegin(ThriftCompactEncoder.I64, 5); 1025 enc.writeI64(cc.numValues); 1026 enc.writeFieldBegin(ThriftCompactEncoder.I64, 6); 1027 enc.writeI64(cc.totalUncompressedSize); 1028 enc.writeFieldBegin(ThriftCompactEncoder.I64, 7); 1029 enc.writeI64(cc.totalCompressedSize); 1030 enc.writeFieldBegin(ThriftCompactEncoder.I64, 9); 1031 enc.writeI64(cc.fileOffset); 1032 enc.writeStructEnd(); 1033 } 1034 1035 private record RowGroupMeta(List<ColumnChunkMeta> columns, long totalByteSize, long numRows, long fileOffset, long totalCompressedSize) {} 1036 private record ColumnChunkMeta(String path, List<String> pathInSchema, int parquetType, long numValues, long totalUncompressedSize, long totalCompressedSize, long fileOffset, int headerSize) {} 1037}