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.commons.utils.SystemUtils.*; 021import static org.apache.juneau.marshall.parquet.ParquetSchemaElement.*; 022 023import java.io.*; 024import java.lang.reflect.*; 025import java.nio.charset.*; 026import java.util.*; 027 028import org.apache.juneau.marshall.*; 029import org.apache.juneau.marshall.collections.*; 030import org.apache.juneau.marshall.parser.*; 031import org.apache.juneau.marshall.stream.*; 032 033/** 034 * Session for {@link ParquetParser}. 035 */ 036@SuppressWarnings({ 037 "java:S107", // readAllRows(), readRowGroupRows() and the FileMeta record carry the fixed per-file schema maps (repetition, raw-byte-array paths, UUID paths, logical types) through the column decode 038 "java:S110", // Extends the shared InputStreamParserSession > ParserSession hierarchy used by every binary format parser session 039 "java:S1192", // Duplicated literals (.list.element, root.list.element., value) are schema keys; constants would obscure 040 "java:S2583", // parquetDebug() is runtime-configurable via setDebugEnabled/-Djuneau.parquet.debug 041 "java:S3776", // doRead(), readSchema() and reconstructNestedListColumn() each walk the Parquet structure (schema tree, scalar-vs-bean-vs-collection targets, repetition/definition levels) in one pass 042 "java:S6539", // Monster class; ParquetParserSession is intentionally a single cohesive Parquet codec session 043 "java:S6541", // Brain Method: Parquet parsing/serialization flows are inherently branchy 044 "resource", // RecordReader returned by RecordAdapter is a Closeable owned by the caller; Eclipse JDT @Owning warning is by design. 045 "unchecked" // doRead() casts the converted row/array/collection result to T, and Collection<Object> for the target collection type, both guaranteed by the requested ClassMeta 046}) 047public class ParquetParserSession extends InputStreamParserSession implements RecordReadable, ArrayRecordReadable { 048 049 private static final byte[] MAGIC = "PAR1".getBytes(StandardCharsets.UTF_8); 050 051 /** 052 * Explicit override set via {@link #setDebugEnabled}. When {@code null}, the debug state is read 053 * from the {@code juneau.parquet.debug} setting through the settings lookup chain (via {@code env(...)}), 054 * making it test-overridable via {@code Settings.setLocal}/{@code setGlobal}. 055 */ 056 private static Boolean parquetDebugOverride; 057 058 /** 059 * Enable debug output for schema/column tracing. 060 * 061 * @param enabled <jk>true</jk> to enable debug logging to temp file. 062 */ 063 public static void setDebugEnabled(boolean enabled) { 064 parquetDebugOverride = enabled; 065 } 066 067 private static boolean parquetDebug() { 068 return parquetDebugOverride != null ? parquetDebugOverride : env("juneau.parquet.debug", false); 069 } 070 071 private static void parquetDebugLog(String msg) { 072 try { 073 var f = java.nio.file.Paths.get(env("java.io.tmpdir", "/tmp"), "juneau-parquet-debug.log"); 074 java.nio.file.Files.writeString(f, "[Parquet DEBUG] " + msg + "\n", 075 java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND); 076 } catch (@SuppressWarnings("unused") IOException ignored) { 077 // ignore 078 } 079 } 080 081 /** 082 * Builder for parser sessions. 083 */ 084 public static class Builder extends InputStreamParserSession.Builder<Builder> { 085 086 private final ParquetParser ctx; 087 088 /** 089 * Constructor. 090 * 091 * @param ctx The parser context. 092 * <br>Cannot be <jk>null</jk>. 093 */ 094 protected Builder(ParquetParser ctx) { 095 super(reqnn("ctx", ctx)); 096 this.ctx = ctx; 097 } 098 099 @Override 100 public ParquetParserSession build() { 101 return new ParquetParserSession(this); 102 } 103 } 104 105 /** 106 * Creates a new session builder. 107 * 108 * @param ctx The parser context. 109 * <br>Cannot be <jk>null</jk>. 110 * @return A new builder. 111 */ 112 public static Builder create(ParquetParser ctx) { 113 return new Builder(reqnn("ctx", ctx)); 114 } 115 116 private final ParquetParser ctx; 117 118 protected ParquetParserSession(Builder builder) { 119 super(builder); 120 ctx = builder.ctx; 121 } 122 123 /** 124 * Resolves the configured {@link ParquetParser#getMaxLength()} to an effective bound, translating 125 * the "disabled" sentinel (values ≤ 0) to {@link Integer#MAX_VALUE}. 126 */ 127 private int effectiveMaxLength() { 128 var v = ctx.getMaxLength(); 129 return v <= 0 ? Integer.MAX_VALUE : v; 130 } 131 132 /** 133 * Resolves the configured {@link ParquetParser#getMaxCount()} to an effective bound, translating 134 * the "disabled" sentinel (values ≤ 0) to {@link Long#MAX_VALUE}. 135 */ 136 private long effectiveMaxCount() { 137 var v = ctx.getMaxCount(); 138 return v <= 0 ? Long.MAX_VALUE : v; 139 } 140 141 /** 142 * Resolves the configured {@link ParquetParser#getMaxInputLength()} to an effective bound, translating 143 * the {@code <= 0} "disabled" sentinel to {@link Long#MAX_VALUE}. 144 * 145 * @return The effective whole-input byte cap. 146 */ 147 private long effectiveMaxInputLength() { 148 var v = ctx.getMaxInputLength(); 149 return v <= 0 ? Long.MAX_VALUE : v; 150 } 151 152 /** 153 * Resolves the configured {@link ParquetParser#getMaxDecompressedBytes()} to an effective bound, 154 * translating the {@code <= 0} "disabled" sentinel to {@link Long#MAX_VALUE}. 155 * 156 * @return The effective aggregate decompressed-byte budget. 157 */ 158 private long effectiveMaxDecompressedBytes() { 159 var v = ctx.getMaxDecompressedBytes(); 160 return v <= 0 ? Long.MAX_VALUE : v; 161 } 162 163 /** 164 * Opens a whole-value pull-parser cursor over a Parquet document, bound to this live session. 165 * {@link RecordReader#read(Class) read(...)} delegates to the polymorphic 166 * {@link ParserSession#read(Object, Class)} entry point. 167 * 168 * @param input The input. 169 * @return A new {@link RecordReader} cursor. 170 * @throws IOException If a problem occurred opening the underlying input. 171 */ 172 @Override /* RecordReadable */ 173 public RecordReader readRecords(Object input) throws IOException { 174 return RecordAdapter.reader(this, input); 175 } 176 177 /** 178 * Buffered array-element {@link RecordReader} for Parquet, bound to this live session. Calls 179 * {@code parse(input, List.class, Object.class)} once and iterates the result. 180 * 181 * <h5 class='section'>Why this stays buffered (not O(1) streaming):</h5> 182 * <p> 183 * Although a Parquet file is logically row-shaped, it is <b>not</b> a forward, element-at-a-time 184 * wire format. Its metadata footer (schema + row-group/column-chunk offsets) lives at the 185 * <b>end</b> of the file, so a reader must seek to the tail before it can interpret any row, and 186 * values are stored <b>column-major</b> — reconstructing one logical record requires 187 * reading one page from every column chunk. Both properties force whole-file (or at minimum 188 * whole-row-group) buffering; a true O(1)-in-rows cursor is not achievable over the current 189 * single-row-group in-memory decoder. Left buffered by design. 190 * 191 * @param input The input. 192 * @return A buffered {@link RecordReader}. 193 * @throws IOException If a problem occurred reading the input. 194 */ 195 @Override /* ArrayRecordReadable */ 196 public RecordReader readArrayRecords(Object input) throws IOException { 197 return RecordAdapter.arrayReader(this, input); 198 } 199 200 /** 201 * The Parquet record cursor is buffered/{@link RecordAdapter}-backed, not O(1) streaming. 202 * 203 * @return Always <jk>false</jk>. 204 */ 205 @Override /* RecordReadable */ 206 public boolean isRecordStreaming() { return false; } 207 208 /** 209 * The Parquet array-record cursor is buffered/{@link RecordAdapter}-backed, not O(1) streaming. 210 * 211 * <p> 212 * Parquet's end-of-file metadata footer and column-major value layout require whole-file 213 * buffering before any record can be reconstructed, so genuine element-at-a-time streaming is 214 * not feasible here; see {@link #readArrayRecords(Object)} for the full rationale. 215 * 216 * @return Always <jk>false</jk>. 217 */ 218 @Override /* ArrayRecordReadable */ 219 public boolean isArrayRecordStreaming() { return false; } 220 221 @Override /* InputStreamParserSession */ 222 public boolean hasNativeBytes() { 223 // Parquet's column reader has no native byte-array primitive type — it surfaces byte[] as a raw 224 // BYTE_ARRAY column (at BinaryFormat.NOT_SET) or as a UTF-8 string column 225 // (at any other BinaryFormat, after the BinarySwap reverses the configured text wire form). 226 return false; 227 } 228 229 @Override 230 @SuppressWarnings({ 231 "java:S9391" // Hot path; avoids stream allocation (and loop bodies throw checked ParseException). 232 }) 233 protected <T> T doRead(ParserPipe pipe, ClassMeta<T> type) throws IOException, ParseException { 234 var bytes = readAllBytes(pipe, effectiveMaxInputLength()); 235 if (bytes.length < 12) 236 throw new ParseException("Parquet file too small"); 237 if (!startsWith(bytes, MAGIC, 0) || !startsWith(bytes, MAGIC, bytes.length - 4)) 238 throw new ParseException("Invalid Parquet magic"); 239 int footerLen = readLe4(bytes, bytes.length - 8); 240 int footerStart = bytes.length - 8 - footerLen; 241 if (footerStart < 4) 242 throw new ParseException("Invalid footer length"); 243 var footer = Arrays.copyOfRange(bytes, footerStart, bytes.length - 8); 244 var meta = readFileMetaData(footer); 245 if (meta.numRows() < 0 || meta.numRows() > effectiveMaxCount()) 246 throw new ParseException("Invalid numRows: %s", meta.numRows()); 247 ClassMeta<?> effectiveType = type; 248 if (type.isOptional()) 249 effectiveType = type.getElementType(); 250 var elementType = effectiveType.isCollection() ? effectiveType.getElementType() : effectiveType; 251 if (elementType == null || elementType.isObject()) 252 elementType = ctx.getMarshallingContext().getClassMeta(Map.class); 253 // For scalar types, use Map so reassembleRows keeps raw map rows for unwrap logic 254 if (!effectiveType.isMap() && !effectiveType.isCollection() && !effectiveType.isArray()) { 255 var inner = effectiveType.inner(); 256 if (inner != null && (inner.isPrimitive() || inner == String.class || inner == Boolean.class 257 || inner == Character.class || Number.class.isAssignableFrom(inner))) 258 elementType = ctx.getMarshallingContext().getClassMeta(Map.class); 259 } 260 var rows = readAllRows(bytes, meta, elementType, meta.schemaRepetition(), meta.rawByteArrayPaths(), meta.uuidPaths(), meta.columnLogical()); 261 // Unwrap ValueHolder {value: X} when single row has "value" key and target expects scalar (not Map/List<Map>) 262 boolean targetWantsScalar = !type.isMap() 263 && !(type.isCollection() && type.getElementType() != null && type.getElementType().isMap()) 264 && !(type.isArray() && type.getElementType() != null && type.getElementType().isMap()); 265 if (rows.size() == 1 && rows.get(0) instanceof Map<?,?> m && m.containsKey("value") && targetWantsScalar) { 266 var inner = m.get("value"); 267 if (type.isOptional()) 268 return (T)o(convertToType(inner, type.getElementType())); 269 if (type.isArray()) { 270 var list = new ArrayList<>(List.of(inner)); 271 return (T)convertToType(list, type); 272 } 273 if (type.isCollection()) { 274 Object coll = type.newInstance(); 275 if (coll == null) 276 coll = l(); 277 ((Collection<Object>)coll).add(convertToType(prepareMapForBean(inner, type.getElementType()), type.getElementType())); 278 return (T)coll; 279 } 280 return (T)convertToType(prepareMapForBean(inner, type), type); 281 } 282 if (type.isMap()) { 283 // Unwrap ValueHolder {value: Map} when addRootType wrapped root Map (2.2) 284 if (rows.size() == 1 && rows.get(0) instanceof Map<?,?> m && m.containsKey("value") && m.size() == 1) { 285 var inner = m.get("value"); 286 if (inner instanceof Map<?,?>) 287 return (T)convertToType(inner, type); 288 } 289 var keyType = type.getKeyType(); 290 if (keyType != null && !keyType.isAssignableTo(String.class) && isKeyValuePairFormat(rows)) { 291 var valueType = type.getValueType(); 292 if (valueType == null) valueType = ctx.getMarshallingContext().getClassMeta(Object.class); 293 var result = m(); 294 for (var row : rows) { 295 var rowMap = (Map<?,?>)row; 296 var rawKey = rowMap.get("key"); 297 var k = (rawKey != null && ctx.nullKeyString.equals(String.valueOf(rawKey))) 298 ? null 299 : convertToType(rawKey, keyType); 300 var v = convertToType(rowMap.get("value"), valueType); 301 result.put(k, v); 302 } 303 return (T)convertToType(result, type); 304 } 305 if (rows.size() == 1 && rows.get(0) instanceof Map<?,?> m) { 306 // Unwrap {root: {...}} from buildSchemaFromMap flat format 307 if (m.size() == 1 && m.containsKey("root") && m.get("root") instanceof Map<?,?> inner) 308 return (T)convertToType(prepareMapForBean(inner, type), type); 309 return (T)convertToType(prepareMapForBean(m, type), type); 310 } 311 } 312 if (type.isOptional()) { 313 if (rows.isEmpty()) 314 return (T)oe(); 315 var inner = unwrapValueHolder(rows.get(0), type.getElementType()); 316 return (T)o(inner); 317 } 318 if (type.isArray()) { 319 var componentMeta = type.getElementType(); 320 var unwrapped = l(); 321 for (var row : rows) { 322 var v = unwrapValueHolder(row, componentMeta); 323 unwrapped.add(v); 324 } 325 return (T)toArray(unwrapped, componentMeta); 326 } 327 if (type.isCollection()) { 328 var collElemType = type.getElementType(); 329 var unwrapped = l(); 330 for (var row : rows) { 331 var v = unwrapValueHolder(row, collElemType); 332 unwrapped.add(v); 333 } 334 return (T)toCollection(unwrapped, type); 335 } 336 if (rows.size() == 1) { 337 var row0 = rows.get(0); 338 var unwrapped = unwrapValueHolder(row0, type); 339 return (T)unwrapped; 340 } 341 if (rows.isEmpty()) 342 return null; 343 return (T)rows; 344 } 345 346 private static byte[] readAllBytes(ParserPipe pipe, long maxInputLength) throws IOException, ParseException { 347 var cap = maxInputLength <= 0 ? Long.MAX_VALUE : maxInputLength; 348 try (var is = pipe.getInputStream()) { 349 var baos = new ByteArrayOutputStream(); 350 var buf = new byte[8192]; 351 int n; 352 long total = 0; 353 while ((n = is.read(buf)) >= 0) { 354 total += n; 355 if (total > cap) 356 throw new ParseException("Parquet input length exceeds maximum allowed %s bytes", cap); 357 baos.write(buf, 0, n); 358 } 359 return baos.toByteArray(); 360 } 361 } 362 363 private static boolean startsWith(byte[] a, byte[] b, int off) { 364 for (int i = 0; i < b.length; i++) 365 if (a[off + i] != b[i]) return false; 366 return true; 367 } 368 369 private static int readLe4(byte[] b, int off) { 370 return (b[off] & 0xFF) | ((b[off + 1] & 0xFF) << 8) | ((b[off + 2] & 0xFF) << 16) | ((b[off + 3] & 0xFF) << 24); 371 } 372 373 /** 374 * Default cap on wire-declared row/element counts (file/row-group row counts, column-chunk value 375 * counts), used when {@link ParquetParser#getMaxCount()} has not been explicitly configured. 376 */ 377 static final int DEFAULT_MAX_COUNT = 10_000_000; 378 379 /** 380 * Default cap (256 MiB) on the whole Parquet input buffered into memory before parsing, used when 381 * {@link ParquetParser#getMaxInputLength()} has not been explicitly configured. 382 */ 383 static final int DEFAULT_MAX_INPUT_LENGTH = 256 * 1024 * 1024; 384 385 /** 386 * Default cap (64 MiB) on the aggregate decompressed-byte total across every page read during a 387 * single parse, used when {@link ParquetParser#getMaxDecompressedBytes()} has not been explicitly 388 * configured. 389 */ 390 static final int DEFAULT_MAX_DECOMPRESSED_BYTES = 64 * 1024 * 1024; 391 392 /** 393 * Clamps an untrusted row-count to a sane initial-capacity ceiling before it is handed to an 394 * {@link ArrayList} constructor. Defense in depth: the count is already bounded upstream (see 395 * {@link #readAllRows}), but sizing every reassembly buffer through this guard keeps a stray value 396 * from ever driving a huge pre-allocation. 397 * 398 * @param numRows The row count. 399 * @param maxCount The configured maximum count (see {@link ParquetParser.Builder#maxCount(int)}). 400 * @return {@code numRows} clamped to {@code [0, maxCount]}. 401 */ 402 // Package-private for focused unit testing of the capacity clamp. 403 static int clampCapacity(int numRows, long maxCount) { 404 return (int)Math.max(0L, Math.min(numRows, maxCount)); 405 } 406 407 /** 408 * Validates a length prefix read from a (decompressed) page body before it is used to size an 409 * allocation. 410 * 411 * <p> 412 * Guards against malformed/adversarial pages where a tiny page body declares a huge rep/def-level 413 * length that would otherwise drive {@link Arrays#copyOfRange} to allocate far beyond the actual page 414 * size. Mirrors {@code BsonInputStream.checkLength} semantics (reject negative, reject beyond the 415 * bytes actually available). 416 * 417 * @param len The declared length read from the page body. 418 * @param available The number of bytes actually available for this length's payload. 419 * @param what A short description of the field being read (for the error message). 420 * @param columnPath The column path (for the error message only). 421 * @return The validated length. 422 * @throws ParseException If the length is negative or exceeds the available bytes. 423 */ 424 // Package-private for focused unit testing of the page-length bound. 425 static int checkPageLength(int len, int available, String what, String columnPath) throws ParseException { 426 if (len < 0 || len > available) 427 throw new ParseException("Invalid Parquet %s length for column '%s': %s (available: %s)", what, columnPath, len, available); 428 return len; 429 } 430 431 /** Sentinel for a null intermediate OPTIONAL group at the given def level (GAP-14 multi-level nesting). */ 432 private record GroupNull(int defLevel) {} 433 434 /** 435 * Mutable per-parse aggregate decompressed-byte budget, threaded through every {@code codec.decompress} 436 * call site (plain, list, and map column-chunk page reads) so many pages that each individually pass 437 * the per-page {@code maxLength} cap cannot together accumulate into an unbounded decompression bomb. 438 */ 439 private static final class DecompressionBudget { 440 441 private long remaining; 442 443 DecompressionBudget(long remaining) { 444 this.remaining = remaining; 445 } 446 447 /** 448 * Charges {@code uncompressedSize} bytes against the running budget before the page is actually 449 * decompressed, so an over-budget page fails fast without performing the decompression work. 450 * 451 * @throws ParseException If the charge would drive the remaining budget negative. 452 */ 453 void charge(long uncompressedSize, String columnPath) throws ParseException { 454 remaining -= uncompressedSize; 455 if (remaining < 0) 456 throw new ParseException("Column '%s' exceeds the aggregate decompressed-byte budget", columnPath); 457 } 458 } 459 460 private record FileMeta(long numRows, List<RowGroupMeta> rowGroups, Map<String,Integer> schemaRepetition, Set<String> rawByteArrayPaths, Set<String> uuidPaths, Map<String,ColumnLogical> columnLogical) {} 461 private record RowGroupMeta(long numRows, List<ColumnChunkMeta> columns) {} 462 private record ColumnChunkMeta(int type, List<String> pathInSchema, int codec, long numValues, long dataPageOffset, long totalCompressedSize) {} 463 464 // Parquet page types (PageHeader.type). 465 private static final int PAGE_DATA = 0; 466 private static final int PAGE_DICTIONARY = 2; 467 468 // Parquet encodings (DataPageHeader.encoding). 469 private static final int ENCODING_PLAIN = 0; 470 private static final int ENCODING_PLAIN_DICTIONARY = 2; 471 private static final int ENCODING_RLE_DICTIONARY = 8; 472 473 /** 474 * One decoded Parquet page header plus the file offset at which its (possibly compressed) page 475 * body begins. Covers both {@code DataPageHeader} (field 5) and {@code DictionaryPageHeader} 476 * (field 7); the unused sub-header's fields are left at their defaults. 477 * 478 * @param pageType {@code PageHeader.type} — {@link #PAGE_DATA}, {@link #PAGE_DICTIONARY}, etc. 479 * @param uncompressedSize Uncompressed page-body size in bytes. 480 * @param compressedSize Compressed page-body size in bytes (equals uncompressedSize when UNCOMPRESSED). 481 * @param numValues Value count carried by the page's own sub-header. 482 * @param encoding Data/dictionary page encoding ({@link #ENCODING_PLAIN}, {@link #ENCODING_RLE_DICTIONARY}, …). 483 * @param bodyStart File offset where the page body (compressed bytes) starts. 484 */ 485 private record PageHeaderInfo(int pageType, int uncompressedSize, int compressedSize, int numValues, int encoding, int bodyStart) {} 486 487 /** 488 * Default cap (256 MiB) on a single Parquet page's declared byte size (compressed or uncompressed), 489 * used when {@link ParquetParser#getMaxLength()} has not been explicitly configured. 490 */ 491 static final int DEFAULT_MAX_LENGTH = 256 * 1024 * 1024; 492 493 /** 494 * Reads a single Parquet {@code PageHeader} Thrift struct starting at {@code off} in {@code fileBytes}. 495 * 496 * @param fileBytes The whole file. 497 * @param off Offset of the page header. 498 * @param columnPath Column path (for error messages only). 499 * @param maxLength The configured maximum page byte size (see {@link ParquetParser.Builder#maxLength(int)}). 500 * @param maxCount The configured maximum count, applied to the page's own {@code num_values} sub-header field 501 * (see {@link ParquetParser.Builder#maxCount(int)}). 502 * @return The decoded header plus the offset where the page body begins. 503 * @throws ParseException If the header is malformed or declares out-of-range sizes. 504 * @throws IOException If the underlying Thrift decode fails. 505 */ 506 private static PageHeaderInfo readPageHeader(byte[] fileBytes, int off, String columnPath, int maxLength, long maxCount) throws ParseException, IOException { 507 var bais = new ByteArrayInputStream(fileBytes, off, fileBytes.length - off); 508 var dec = new ThriftCompactDecoder(bais); 509 dec.readStructBegin(); 510 int pageType = PAGE_DATA; 511 int uncompressedSize = 0; 512 int compressedSize = 0; 513 int numValues = 0; 514 int encoding = ENCODING_PLAIN; 515 ThriftCompactDecoder.FieldHeader fh; 516 while (!(fh = dec.readFieldHeader()).isStop) { 517 switch (fh.fieldId) { 518 case 1 -> pageType = dec.readI32(); 519 case 2 -> uncompressedSize = dec.readI32(); 520 case 3 -> compressedSize = dec.readI32(); 521 case 5, 7 -> { // DataPageHeader (5) or DictionaryPageHeader (7): both carry num_values(1)+encoding(2). 522 dec.readStructBegin(); 523 ThriftCompactDecoder.FieldHeader sub; 524 while (!(sub = dec.readFieldHeader()).isStop) { 525 if (sub.fieldId == 1) 526 numValues = dec.readI32(); 527 else if (sub.fieldId == 2) 528 encoding = dec.readI32(); 529 else 530 dec.skipField(sub.type); 531 } 532 dec.readStructEnd(); 533 } 534 default -> dec.skipField(fh.type); 535 } 536 } 537 dec.readStructEnd(); 538 int headerConsumed = (fileBytes.length - off) - bais.available(); 539 int bodyStart = off + headerConsumed; 540 if (uncompressedSize < 0 || uncompressedSize > maxLength 541 || compressedSize < 0 || compressedSize > maxLength 542 || bodyStart + compressedSize > fileBytes.length) 543 throw new ParseException("Invalid page header for column '%s': uncompressed='%s', compressed='%s'", 544 columnPath, uncompressedSize, compressedSize); 545 if (numValues < 0 || numValues > maxCount) 546 throw new ParseException("Invalid page num_values for column '%s': %s", columnPath, numValues); 547 return new PageHeaderInfo(pageType, uncompressedSize, compressedSize, numValues, encoding, bodyStart); 548 } 549 550 /** 551 * Returns the offset of the first {@code DATA_PAGE} at or after {@code off}, skipping a leading 552 * {@code DICTIONARY_PAGE} if one is present. Used by the repeated (list/map) chunk readers, which 553 * decode a single data page but must still tolerate a dictionary page in front of it. 554 */ 555 private static int skipToDataPage(byte[] fileBytes, int off, String columnPath, int maxLength, long maxCount) throws ParseException, IOException { 556 var ph = readPageHeader(fileBytes, off, columnPath, maxLength, maxCount); 557 if (ph.pageType() == PAGE_DICTIONARY) 558 return ph.bodyStart() + ph.compressedSize(); 559 return off; 560 } 561 562 /** 563 * Result of {@link #readSchema(ThriftCompactDecoder)}: the per-leaf repetition map plus the set of 564 * leaf paths whose physical type is {@code TYPE_BYTE_ARRAY} with no {@code convertedType} — 565 * i.e. raw {@code byte[]} columns emitted by {@code ParquetSchemaBuilder.addLeafSchema}'s 566 * {@code cm.isByteArray()} branch. The Parquet file footer drops the 567 * {@code logicalType} discriminant ({@code ParquetSchemaElement.writeTo} omits field 7), so the 568 * parser uses "{@code TYPE_BYTE_ARRAY} with no {@code convertedType}" as the unique signal that a 569 * column should be reassembled back into {@code byte[]} instead of decoded as UTF-8 text. 570 * 571 * <p> 572 * {@code uuidPaths} carries the set of leaf paths that should be decoded as {@link UUID}. The decision 573 * prefers the explicit {@code LogicalType} discriminant (field 10) when present — a column is a UUID iff 574 * its logical type is UUID — and falls back to the legacy "{@code FIXED_LEN_BYTE_ARRAY} == UUID" 575 * physical-type heuristic when no logical type is recorded (backward compatibility with files written 576 * without the opt-in discriminator). 577 */ 578 private record SchemaReadResult(Map<String,Integer> repetitions, Set<String> rawByteArrayPaths, Set<String> uuidPaths, Map<String,ColumnLogical> columnLogical) {} 579 580 /** 581 * Per-leaf logical-type metadata captured from the schema, used to decode native logical-type columns 582 * (GAP-9/10): the {@code convertedType} (parquet.thrift ConvertedType), DECIMAL {@code scale}/{@code precision}, 583 * and the physical {@code type}. All fields are best-effort: {@code convertedType} is null when absent. 584 */ 585 record ColumnLogical(Integer convertedType, int scale, int precision) {} 586 587 private static FileMeta readFileMetaData(byte[] footer) throws ParseException { 588 try { 589 var dec = new ThriftCompactDecoder(footer); 590 dec.readStructBegin(); 591 long numRows = 0; 592 List<RowGroupMeta> rowGroups = null; 593 Map<String,Integer> schemaRepetition = Map.of(); 594 Set<String> rawByteArrayPaths = Set.of(); 595 Set<String> uuidPaths = Set.of(); 596 Map<String,ColumnLogical> columnLogical = Map.of(); 597 ThriftCompactDecoder.FieldHeader fh; 598 while (!(fh = dec.readFieldHeader()).isStop) { 599 switch (fh.fieldId) { 600 case 1 -> dec.readI32(); // version - consumed but not used 601 case 2 -> { 602 var sr = readSchema(dec); 603 schemaRepetition = sr.repetitions(); 604 rawByteArrayPaths = sr.rawByteArrayPaths(); 605 uuidPaths = sr.uuidPaths(); 606 columnLogical = sr.columnLogical(); 607 } 608 case 3 -> numRows = dec.readI64(); 609 case 4 -> rowGroups = readRowGroups(dec); 610 default -> dec.skipField(fh.type); 611 } 612 } 613 dec.readStructEnd(); 614 return new FileMeta(numRows, rowGroups != null ? rowGroups : List.of(), schemaRepetition, rawByteArrayPaths, uuidPaths, columnLogical); 615 } catch (IOException e) { 616 throw new ParseException(e); 617 } 618 } 619 620 private record SchemaStackFrame(String name, int remaining) {} 621 622 private static SchemaReadResult readSchema(ThriftCompactDecoder dec) throws IOException { 623 var lh = dec.readListHeader(); 624 var pathStack = new ArrayList<SchemaStackFrame>(); 625 var result = new LinkedHashMap<String,Integer>(); 626 var rawByteArrayPaths = new LinkedHashSet<String>(); 627 var uuidPaths = new LinkedHashSet<String>(); 628 var columnLogical = new LinkedHashMap<String,ColumnLogical>(); 629 for (int i = 0; i < lh.size; i++) { 630 dec.readStructBegin(); 631 Integer type = null; 632 Integer repetitionType = null; 633 String name = null; 634 Integer numChildren = null; 635 Integer convertedType = null; 636 Integer logicalType = null; 637 int scale = 0; 638 int precision = 0; 639 ThriftCompactDecoder.FieldHeader fh; 640 while (!(fh = dec.readFieldHeader()).isStop) { 641 switch (fh.fieldId) { 642 case 1 -> type = dec.readI32(); 643 case 2 -> dec.readI32(); 644 case 3 -> repetitionType = dec.readI32(); 645 case 4 -> name = dec.readString(); 646 case 5 -> numChildren = dec.readI32(); 647 case 6 -> convertedType = dec.readI32(); 648 case 7 -> scale = dec.readI32(); // SchemaElement.scale (DECIMAL) 649 case 8 -> precision = dec.readI32(); // SchemaElement.precision (DECIMAL) 650 case 10 -> logicalType = readLogicalTypeUnion(dec); 651 default -> dec.skipField(fh.type); 652 } 653 } 654 dec.readStructEnd(); 655 if (name == null) 656 continue; 657 var parts = new ArrayList<>(pathStack.stream().map(f -> f.name).toList()); 658 parts.add(name); 659 var path = String.join(".", parts); 660 if (numChildren != null && numChildren > 0) { 661 pathStack.add(new SchemaStackFrame(name, numChildren)); 662 } else if (type != null) { 663 // Leaf element: record it and decrement parent's child count 664 int rep = or(repetitionType, OPTIONAL); 665 result.put(path, rep); 666 // Capture logical-type metadata for native-type decode (GAP-9/10). Only the DECIMAL and 667 // temporal converted types matter to the decoder; the UUID/string paths are handled separately. 668 if (convertedType != null) 669 columnLogical.put(path, new ColumnLogical(convertedType, scale, precision)); 670 // A TYPE_BYTE_ARRAY column with no convertedType uniquely identifies a 671 // raw byte[] column emitted by ParquetSchemaBuilder.addLeafSchema's cm.isByteArray() 672 // branch. Every other writer site that emits TYPE_BYTE_ARRAY sets a discriminator 673 // (CONVERTED_UTF8 for strings/dates/durations/decimals, CONVERTED_ENUM for enums, or 674 // CONVERTED_TIMESTAMP_MILLIS). Mirrors the UUID parse-back assumption 675 // (TYPE_FIXED_LEN_BYTE_ARRAY uniquely identifies UUIDs) at a different physical type. 676 if (type == TYPE_BYTE_ARRAY && convertedType == null) 677 rawByteArrayPaths.add(path); 678 // Prefer the explicit LogicalType discriminant (field 10) when present; otherwise fall back to 679 // the legacy "FIXED_LEN_BYTE_ARRAY == UUID" physical-type heuristic for backward compatibility 680 // with files written without the opt-in discriminator. 681 if (type == TYPE_FIXED_LEN_BYTE_ARRAY) 682 uuidPaths.add(path); 683 if (parquetDebug()) 684 parquetDebugLog("readSchema leaf: path=" + path + " name=" + name + " rep=" + rep 685 + " convertedType=" + convertedType + " logicalType=" + logicalType + " pathStack=" + pathStack); 686 boolean moreToDecrement = true; 687 while (!pathStack.isEmpty() && moreToDecrement) { 688 var top = pathStack.get(pathStack.size() - 1); 689 var newRemaining = top.remaining - 1; 690 if (newRemaining <= 0) 691 pathStack.remove(pathStack.size() - 1); 692 else { 693 pathStack.set(pathStack.size() - 1, new SchemaStackFrame(top.name, newRemaining)); 694 moreToDecrement = false; 695 } 696 } 697 } 698 } 699 if (parquetDebug()) 700 parquetDebugLog("readSchema result: " + result + " rawByteArrayPaths=" + rawByteArrayPaths + " uuidPaths=" + uuidPaths); 701 return new SchemaReadResult(result, rawByteArrayPaths, uuidPaths, columnLogical); 702 } 703 704 /** 705 * Reads a Parquet {@code LogicalType} union (parquet.thrift field 10) and returns the discriminant of 706 * the set union member (e.g. {@link ParquetSchemaElement#LOGICAL_TYPE_UUID} for {@code UUIDType}). 707 * 708 * <p> 709 * A Thrift union is encoded like a struct with exactly one field set; the field id is the union 710 * discriminant. Returns <jk>null</jk> if the union is empty (no member set). 711 */ 712 private static Integer readLogicalTypeUnion(ThriftCompactDecoder dec) throws IOException { 713 dec.readStructBegin(); 714 Integer discriminant = null; 715 ThriftCompactDecoder.FieldHeader fh; 716 while (!(fh = dec.readFieldHeader()).isStop) { 717 if (discriminant == null) 718 discriminant = fh.fieldId; 719 dec.skipField(fh.type); 720 } 721 dec.readStructEnd(); 722 return discriminant; 723 } 724 725 private static void skipList(ThriftCompactDecoder dec) throws IOException { 726 var lh = dec.readListHeader(); 727 for (int i = 0; i < lh.size; i++) 728 dec.skipField(lh.elemType); 729 } 730 731 private static List<RowGroupMeta> readRowGroups(ThriftCompactDecoder dec) throws IOException { 732 var lh = dec.readListHeader(); 733 var list = new ArrayList<RowGroupMeta>(lh.size); 734 for (int i = 0; i < lh.size; i++) { 735 dec.readStructBegin(); 736 List<ColumnChunkMeta> columns = null; 737 long groupNumRows = 0; 738 ThriftCompactDecoder.FieldHeader fh; 739 while (!(fh = dec.readFieldHeader()).isStop) { 740 if (fh.fieldId == 1) 741 columns = readColumnChunks(dec); 742 else if (fh.fieldId == 3) 743 groupNumRows = dec.readI64(); // RowGroup.num_rows 744 else 745 dec.skipField(fh.type); 746 } 747 dec.readStructEnd(); 748 list.add(new RowGroupMeta(groupNumRows, columns != null ? columns : List.of())); 749 } 750 return list; 751 } 752 753 private static List<ColumnChunkMeta> readColumnChunks(ThriftCompactDecoder dec) throws IOException { 754 var lh = dec.readListHeader(); 755 var list = new ArrayList<ColumnChunkMeta>(lh.size); 756 for (int i = 0; i < lh.size; i++) { 757 dec.readStructBegin(); 758 ColumnChunkMeta meta = null; 759 ThriftCompactDecoder.FieldHeader fh; 760 while (!(fh = dec.readFieldHeader()).isStop) { 761 if (fh.fieldId == 2) 762 dec.readI64(); // file offset - consumed but not used 763 else if (fh.fieldId == 3) 764 meta = readColumnMetaData(dec); 765 else 766 dec.skipField(fh.type); 767 } 768 dec.readStructEnd(); 769 if (meta != null) 770 list.add(new ColumnChunkMeta(meta.type(), meta.pathInSchema(), meta.codec(), meta.numValues(), meta.dataPageOffset(), meta.totalCompressedSize())); 771 } 772 return list; 773 } 774 775 private static ColumnChunkMeta readColumnMetaData(ThriftCompactDecoder dec) throws IOException { 776 dec.readStructBegin(); 777 int type = 6; 778 List<String> pathInSchema = List.of(); 779 int codec = 0; 780 long numValues = 0; 781 long totalCompressedSize = 0; 782 long dataPageOffset = 0; 783 ThriftCompactDecoder.FieldHeader fh; 784 while (!(fh = dec.readFieldHeader()).isStop) { 785 switch (fh.fieldId) { 786 case 1 -> type = dec.readI32(); 787 case 2 -> skipList(dec); 788 case 3 -> pathInSchema = readStringList(dec); 789 case 4 -> codec = dec.readI32(); 790 case 5 -> numValues = dec.readI64(); 791 case 6 -> dec.readI64(); 792 case 7 -> totalCompressedSize = dec.readI64(); 793 case 9 -> dataPageOffset = dec.readI64(); 794 default -> dec.skipField(fh.type); 795 } 796 } 797 dec.readStructEnd(); 798 return new ColumnChunkMeta(type, pathInSchema, codec, numValues, dataPageOffset, totalCompressedSize); 799 } 800 801 private static List<String> readStringList(ThriftCompactDecoder dec) throws IOException { 802 var lh = dec.readListHeader(); 803 var list = new ArrayList<String>(lh.size); 804 for (int i = 0; i < lh.size; i++) 805 list.add(dec.readString()); 806 return list; 807 } 808 809 private List<?> readAllRows(byte[] fileBytes, FileMeta meta, ClassMeta<?> elementType, Map<String,Integer> schemaRepetition, Set<String> rawByteArrayPaths, Set<String> uuidPaths, Map<String,ColumnLogical> columnLogical) throws ParseException { 810 if (meta.numRows() == 0 || meta.rowGroups().isEmpty()) 811 return List.of(); 812 // Multi-row-group (GAP-2): read every row group and concatenate its reassembled rows in order. 813 // Each group declares its own num_rows; fall back to the file total only for a lone group whose 814 // per-group count wasn't recorded (older Juneau-written files). 815 var maxCount = effectiveMaxCount(); 816 var allRows = new ArrayList<Object>((int)Math.min(meta.numRows(), maxCount)); 817 // Shared across every row group/column in this parse (see DecompressionBudget): per-page maxLength 818 // alone can't stop many individually-valid pages from summing to a decompression bomb. 819 var budget = new DecompressionBudget(effectiveMaxDecompressedBytes()); 820 // Each per-row-group num_rows is untrusted and drives ArrayList pre-allocation and reassembly loops 821 // downstream. Bound every group count (and the running total) against the same configured ceiling 822 // used for the file-level total above, before it is used to size anything, so a tiny footer 823 // declaring a huge per-group count cannot force a giant allocation. 824 long totalGroupRows = 0; 825 for (var group : meta.rowGroups()) { 826 if (group.numRows() < 0 || group.numRows() > maxCount) 827 throw new ParseException("Invalid row group numRows: %s", group.numRows()); 828 totalGroupRows += group.numRows(); 829 if (totalGroupRows > maxCount) 830 throw new ParseException("Total row group numRows %s exceeds maximum allowed %s", totalGroupRows, maxCount); 831 int groupRows = (int)group.numRows(); 832 allRows.addAll(readRowGroupRows(fileBytes, group, groupRows, elementType, schemaRepetition, rawByteArrayPaths, uuidPaths, columnLogical, budget)); 833 } 834 return allRows; 835 } 836 837 private List<?> readRowGroupRows(byte[] fileBytes, RowGroupMeta group, int numRows, ClassMeta<?> elementType, Map<String,Integer> schemaRepetition, Set<String> rawByteArrayPaths, Set<String> uuidPaths, Map<String,ColumnLogical> columnLogical, DecompressionBudget budget) throws ParseException { 838 var columnData = new LinkedHashMap<String,List<Object>>(); 839 var maxLength = effectiveMaxLength(); 840 var maxCount = effectiveMaxCount(); 841 for (var cc : group.columns()) { 842 var path = String.join(".", cc.pathInSchema()); 843 List<Object> values; 844 var trim = isTrimStrings(); 845 if (isListColumnPath(path)) 846 values = readListColumnChunk(fileBytes, cc, numRows, trim, maxLength, maxCount, budget); 847 else if (isMapKeyValueColumnPath(path)) 848 values = readMapKeyValueColumnChunk(fileBytes, cc, numRows, trim, maxLength, maxCount, budget); 849 else 850 values = readColumnChunk(fileBytes, cc, numRows, schemaRepetition, rawByteArrayPaths, uuidPaths, columnLogical, trim, maxLength, maxCount, budget); 851 columnData.put(path, values); 852 } 853 if (parquetDebug()) { 854 var summary = columnData.entrySet().stream() 855 .map(e -> e.getKey() + "->" + e.getValue()) 856 .toList(); 857 parquetDebugLog("readRowGroupRows: numRows=" + numRows + " columnData=" + summary); 858 } 859 return reassembleRows(columnData, numRows, elementType); 860 } 861 862 private static boolean isListColumnPath(String path) { 863 return path.endsWith(".list.element") || path.contains(".list.element."); 864 } 865 866 private static boolean isMapKeyValueColumnPath(String path) { 867 return path.contains(".key_value.key") || path.contains(".key_value.value"); 868 } 869 870 private static String mapPropertyPath(String mapKeyValuePath) { 871 return mapKeyValuePath.substring(0, mapKeyValuePath.indexOf(".key_value.")); 872 } 873 874 /** Returns true if rows are key-value pairs (Map with non-String keys format). */ 875 private static boolean isKeyValuePairFormat(List<?> rows) { 876 if (ie(rows)) 877 return false; 878 var first = (Map<?,?>) rows.get(0); 879 return first.containsKey("key") && first.containsKey("value") && first.size() == 2; 880 } 881 882 /** Path relative to the row (bean). All schema leaf paths start with "root.". */ 883 private static String rowRelativePath(String fullPath) { 884 return fullPath.substring("root.".length()); 885 } 886 887 /** For list column path like "tags.list.element" or "members.list.element.name", returns the bean property name ("tags" or "members"). */ 888 private static String listPropertyPath(String listColumnPath) { 889 int idx = listColumnPath.indexOf(".list.element"); 890 return idx < 0 ? listColumnPath : listColumnPath.substring(0, idx); 891 } 892 893 private static List<Object> readListColumnChunk(byte[] fileBytes, ColumnChunkMeta cc, int numRows, boolean trimStrings, int maxLength, long maxCount, DecompressionBudget budget) throws ParseException { 894 try { 895 var path = String.join(".", cc.pathInSchema()); 896 var rowRelPath = rowRelativePath(path); 897 int listDepth = listDepth(rowRelPath); 898 int maxDef = 3 * listDepth; 899 int maxRep = listDepth; 900 int defBitWidth = 32 - Integer.numberOfLeadingZeros(maxDef); 901 int repBitWidth = 32 - Integer.numberOfLeadingZeros(maxRep); 902 903 if (cc.numValues() < 0 || cc.numValues() > maxCount) 904 throw new ParseException("Invalid numValues for column '%s': %s", String.join(".", cc.pathInSchema()), cc.numValues()); 905 var codec = CompressionCodec.fromThrift(cc.codec()); 906 // Skip a leading dictionary page if present, then read the single data page. Repeated 907 // (list/map) columns carry rep+def levels with cross-page state; Juneau emits them as a single 908 // data page, and dictionary-encoded repeated columns are not produced here, so a single-data-page 909 // decode (via the shared page-header reader for Snappy + validation) is sufficient. 910 var chunkPath = String.join(".", cc.pathInSchema()); 911 int dataPageOff = skipToDataPage(fileBytes, (int)cc.dataPageOffset(), chunkPath, maxLength, maxCount); 912 var ph = readPageHeader(fileBytes, dataPageOff, chunkPath, maxLength, maxCount); 913 budget.charge(ph.uncompressedSize(), chunkPath); 914 var compressedData = Arrays.copyOfRange(fileBytes, ph.bodyStart(), ph.bodyStart() + ph.compressedSize()); 915 var decompressed = codec.decompress(compressedData, ph.uncompressedSize()); 916 917 int numValues = (int)cc.numValues(); 918 // The rep-level and def-level length prefixes are untrusted LE4 values read from the (decompressed) 919 // page body; bound each against the bytes actually available before it is used to size an 920 // Arrays.copyOfRange allocation, so a tiny page cannot declare a huge length and force a giant 921 // buffer. 922 if (decompressed.length < 4) 923 throw new ParseException("Invalid Parquet list page for column '%s': too small for rep-level length", chunkPath); 924 int off2 = 0; 925 int repLen = checkPageLength(readLe4(decompressed, off2), decompressed.length - 4, "list rep-level", chunkPath); 926 off2 += 4 + repLen; 927 int defStart = off2; 928 if (defStart + 4 > decompressed.length) 929 throw new ParseException("Invalid Parquet list page for column '%s': truncated def-level length", chunkPath); 930 int defLen = checkPageLength(readLe4(decompressed, defStart), decompressed.length - defStart - 4, "list def-level", chunkPath); 931 off2 += 4 + defLen; 932 var valueBytes = Arrays.copyOfRange(decompressed, off2, decompressed.length); 933 var repBytes = Arrays.copyOfRange(decompressed, 4, 4 + repLen); 934 var defBytes = Arrays.copyOfRange(decompressed, defStart + 4, defStart + 4 + defLen); 935 var repDecoder = new RleBitPackingDecoder(repBytes, repBitWidth); 936 var defDecoder = new RleBitPackingDecoder(defBytes, defBitWidth); 937 var valueReader = new ParquetColumnReader(valueBytes, numValues, 0); 938 939 var flattened = new ArrayList<Object[]>(); 940 for (int i = 0; i < numValues; i++) { 941 int rep = repDecoder.readInt(); 942 int def = defDecoder.readInt(); 943 Object val = def >= maxDef ? readValue(valueReader, cc.type(), trimStrings) : null; 944 flattened.add(new Object[] { val, rep, def }); 945 } 946 947 if (listDepth == 1) 948 return reconstructRowsFromListColumn(flattened, numRows, maxDef, maxCount); 949 return reconstructNestedListColumn(flattened, numRows, listDepth, maxDef, maxCount); 950 } catch (IOException e) { 951 throw new ParseException(e); 952 } 953 } 954 955 /** 956 * Reconstructs depth-N nested list columns (depth ≥ 2), replacing the former depth-2-only method. 957 * 958 * <p>This unified algorithm supports arbitrarily deep nesting (e.g. {@code boolean[][]}, 959 * {@code List<boolean[]>}, {@code List<Long>[][]}, {@code List<Long[][]>}). 960 * 961 * <h5>Rep level semantics (depth=N, maxRep=N):</h5> 962 * <ul> 963 * <li>rep=0 — new row; flush the entire stack and start fresh 964 * <li>rep=k (0 < k < N) — new element at nesting level k; flush inner levels k+1..N 965 * <li>rep=N — continue the innermost list (append another element) 966 * </ul> 967 * 968 * <h5>Def level semantics (maxDef = 3*N):</h5> 969 * <ul> 970 * <li>def=2*(k-1) — null list at level k (1 ≤ k ≤ N) 971 * <li>def=2*(k-1)+1 — empty list at level k (1 ≤ k ≤ N) 972 * <li>def=maxDef-1 — leaf element is null 973 * <li>def=maxDef — leaf element is present 974 * </ul> 975 * 976 * <p>The "stacks" array tracks the currently open list at each nesting level. 977 * {@code stacks[0]} is the outermost list (what will be placed in the row), 978 * {@code stacks[N-1]} is the innermost list being appended to. 979 */ 980 private static List<Object> reconstructNestedListColumn(List<Object[]> flattened, int numRows, int depth, int maxDef, long maxCount) { 981 var result = new ArrayList<>(clampCapacity(numRows, maxCount)); 982 var stacks = new ArrayList[depth]; 983 984 for (var e : flattened) { 985 Object val = e[0]; 986 int rep = (Integer)e[1]; 987 int def = (Integer)e[2]; 988 989 // Flush: close inner lists from the deepest level down to `rep`, adding each to its parent. 990 // For rep=0 this flushes everything including stacks[0] into result. 991 for (int k = depth - 1; k >= rep; k--) { 992 if (stacks[k] != null) { 993 if (k > 0) 994 stacks[k - 1].add(stacks[k]); 995 else 996 result.add(stacks[k]); 997 stacks[k] = null; 998 } 999 } 1000 1001 // Null at level 1 is the only case where def==0 AND rep==0; it was flushed above. 1002 if (rep == 0 && def == 0) { 1003 result.add(null); // whole field is null 1004 } else if (def <= 2 * (depth - 1) + 1) { 1005 // Null or empty list at nesting level k (1-indexed): def=2*(k-1) → null; def=2*(k-1)+1 → empty. 1006 int level = def / 2 + 1; 1007 boolean isNull = (def % 2 == 0); 1008 // Ensure stacks[start..level-2] exist (parents of the null/empty list). 1009 int startK = (rep == 0) ? 0 : rep; 1010 for (int k = startK; k < level - 1; k++) 1011 if (stacks[k] == null) stacks[k] = new ArrayList<>(); 1012 if (level == 1) { 1013 // Level-1 null is handled above (def==0); level-1 empty reaches here (def==1). 1014 result.add(l()); 1015 } else if (isNull) { 1016 stacks[level - 2].add(null); // null list at level k → parent gets null slot 1017 } else { 1018 stacks[level - 2].add(l()); // empty list at level k 1019 } 1020 } else { 1021 // Element (null if def < maxDef, present if def == maxDef). 1022 if (rep < depth) { 1023 // Initialize stacks from the first missing level down to the innermost. 1024 int startK = (rep == 0) ? 0 : rep; 1025 for (int k = startK; k < depth; k++) 1026 if (stacks[k] == null) stacks[k] = new ArrayList<>(); 1027 } else { 1028 // rep == depth: continue innermost list (defensive init in case it's missing). 1029 if (stacks[depth - 1] == null) stacks[depth - 1] = new ArrayList<>(); 1030 } 1031 stacks[depth - 1].add(def >= maxDef ? val : null); 1032 } 1033 } 1034 1035 // Flush the final row's stacks. 1036 for (int k = depth - 1; k >= 0; k--) { 1037 if (stacks[k] != null) { 1038 if (k > 0) 1039 stacks[k - 1].add(stacks[k]); 1040 else 1041 result.add(stacks[k]); 1042 stacks[k] = null; 1043 } 1044 } 1045 while (result.size() < numRows) 1046 result.add(l()); 1047 return result; 1048 } 1049 1050 private static int listDepth(String path) { 1051 int depth = 0; 1052 for (int idx = 0; (idx = path.indexOf(".list.element", idx)) >= 0; idx += 13) 1053 depth++; 1054 return depth; 1055 } 1056 1057 private static final int MAP_MAX_DEF = 2; 1058 private static final int MAP_MAX_REP = 1; 1059 1060 private static List<Object> readMapKeyValueColumnChunk(byte[] fileBytes, ColumnChunkMeta cc, int numRows, boolean trimStrings, int maxLength, long maxCount, DecompressionBudget budget) throws ParseException { 1061 try { 1062 int maxDef = MAP_MAX_DEF; 1063 int maxRep = MAP_MAX_REP; 1064 int defBitWidth = 32 - Integer.numberOfLeadingZeros(maxDef); 1065 int repBitWidth = 32 - Integer.numberOfLeadingZeros(maxRep); 1066 1067 if (cc.numValues() < 0 || cc.numValues() > maxCount) 1068 throw new ParseException("Invalid numValues for column '%s': %s", String.join(".", cc.pathInSchema()), cc.numValues()); 1069 var codec = CompressionCodec.fromThrift(cc.codec()); 1070 // Skip a leading dictionary page if present, then read the single data page. Repeated 1071 // (list/map) columns carry rep+def levels with cross-page state; Juneau emits them as a single 1072 // data page, and dictionary-encoded repeated columns are not produced here, so a single-data-page 1073 // decode (via the shared page-header reader for Snappy + validation) is sufficient. 1074 var chunkPath = String.join(".", cc.pathInSchema()); 1075 int dataPageOff = skipToDataPage(fileBytes, (int)cc.dataPageOffset(), chunkPath, maxLength, maxCount); 1076 var ph = readPageHeader(fileBytes, dataPageOff, chunkPath, maxLength, maxCount); 1077 budget.charge(ph.uncompressedSize(), chunkPath); 1078 var compressedData = Arrays.copyOfRange(fileBytes, ph.bodyStart(), ph.bodyStart() + ph.compressedSize()); 1079 var decompressed = codec.decompress(compressedData, ph.uncompressedSize()); 1080 1081 int numValues = (int)cc.numValues(); 1082 // The rep-level and def-level length prefixes are untrusted LE4 values read from the (decompressed) 1083 // page body; bound each against the bytes actually available before it is used to size an 1084 // Arrays.copyOfRange allocation, so a tiny page cannot declare a huge length and force a giant 1085 // buffer. 1086 if (decompressed.length < 4) 1087 throw new ParseException("Invalid Parquet map page for column '%s': too small for rep-level length", chunkPath); 1088 int off2 = 0; 1089 int repLen = checkPageLength(readLe4(decompressed, off2), decompressed.length - 4, "map rep-level", chunkPath); 1090 off2 += 4 + repLen; 1091 int defStart = off2; 1092 if (defStart + 4 > decompressed.length) 1093 throw new ParseException("Invalid Parquet map page for column '%s': truncated def-level length", chunkPath); 1094 int defLen = checkPageLength(readLe4(decompressed, defStart), decompressed.length - defStart - 4, "map def-level", chunkPath); 1095 off2 += 4 + defLen; 1096 var repBytes = Arrays.copyOfRange(decompressed, 4, 4 + repLen); 1097 var defBytes = Arrays.copyOfRange(decompressed, defStart + 4, defStart + 4 + defLen); 1098 var repDecoder = new RleBitPackingDecoder(repBytes, repBitWidth); 1099 var defDecoder = new RleBitPackingDecoder(defBytes, defBitWidth); 1100 var valueBytes = Arrays.copyOfRange(decompressed, off2, decompressed.length); 1101 var valueReader = new ParquetColumnReader(valueBytes, numValues, 0); 1102 1103 var flattened = new ArrayList<Object[]>(); 1104 for (int i = 0; i < numValues; i++) { 1105 int rep = repDecoder.readInt(); 1106 int def = defDecoder.readInt(); 1107 Object val = def >= maxDef ? readValue(valueReader, cc.type(), trimStrings) : null; 1108 flattened.add(new Object[] { val, rep, def }); 1109 } 1110 1111 return reconstructRowsFromListColumn(flattened, numRows, maxDef, maxCount); 1112 } catch (IOException e) { 1113 throw new ParseException(e); 1114 } 1115 } 1116 1117 private static List<Object> reconstructRowsFromListColumn(List<Object[]> flattened, int numRows, int maxDef, long maxCount) { 1118 var result = new ArrayList<>(clampCapacity(numRows, maxCount)); 1119 var current = l(); 1120 // def=0: list null; def=1: list present empty; def>=maxDef-1: element slot (value can be null) 1121 int elemPresentDef = Math.max(0, maxDef - 1); 1122 for (var e : flattened) { 1123 Object val = e[0]; 1124 int rep = (Integer)e[1]; 1125 int def = (Integer)e[2]; 1126 if (rep == 0) { 1127 if (!current.isEmpty()) { 1128 result.add(new ArrayList<>(current)); 1129 current.clear(); 1130 } 1131 if (def == 0) 1132 result.add(null); 1133 else if (def < elemPresentDef) 1134 result.add(l()); 1135 else if (def >= elemPresentDef) 1136 current.add(val); 1137 } else if (def >= elemPresentDef) { 1138 current.add(val); 1139 } 1140 } 1141 if (!current.isEmpty()) 1142 result.add(current); 1143 while (result.size() < numRows) 1144 result.add(List.of()); 1145 return result; 1146 } 1147 1148 private static List<Object> readColumnChunk(byte[] fileBytes, ColumnChunkMeta cc, int numRows, Map<String,Integer> schemaRepetition, Set<String> rawByteArrayPaths, Set<String> uuidPaths, Map<String,ColumnLogical> columnLogical, boolean trimStrings, int maxLength, long maxCount, DecompressionBudget budget) throws ParseException { 1149 try { 1150 if (cc.numValues() < 0 || cc.numValues() > maxCount) 1151 throw new ParseException("Invalid numValues for column '%s': %s", String.join(".", cc.pathInSchema()), cc.numValues()); 1152 var codec = CompressionCodec.fromThrift(cc.codec()); 1153 var path = String.join(".", cc.pathInSchema()); 1154 int rep = schemaRepetition.getOrDefault(path, OPTIONAL); 1155 boolean isUnderListRoot = path.startsWith("root.list.element."); 1156 boolean isPrimitiveType = cc.type() == TYPE_INT32 || cc.type() == TYPE_INT64 || cc.type() == TYPE_FLOAT || cc.type() == TYPE_DOUBLE || cc.type() == TYPE_BOOLEAN; 1157 // Max def level for a non-list/non-map leaf is the number of OPTIONAL groups enclosing it, i.e. the 1158 // rowRelative path segment count (GAP-14). Single-segment columns keep the historical max=1. 1159 int rowRelSeg = rowRelativePath(path).split("\\.").length; 1160 int maxDefLevel = (rep == REQUIRED || (isUnderListRoot && isPrimitiveType)) ? 0 : rowRelSeg; 1161 int defBitWidth = maxDefLevel <= 0 ? 1 : 32 - Integer.numberOfLeadingZeros(maxDefLevel); 1162 boolean isRawByteArrayColumn = rawByteArrayPaths.contains(path); 1163 boolean isUuidColumn = uuidPaths.contains(path); 1164 var logical = columnLogical.get(path); 1165 if (parquetDebug()) 1166 parquetDebugLog("readColumnChunk: path=" + path + " pathInSchema=" + cc.pathInSchema() 1167 + " rep=" + rep + " (REQ=" + REQUIRED + ") maxDefLevel=" + maxDefLevel + " type=" + cc.type() 1168 + " isPrimitive=" + isPrimitiveType + " isRawByteArrayColumn=" + isRawByteArrayColumn 1169 + " isUuidColumn=" + isUuidColumn + " logical=" + logical); 1170 1171 // Page loop (GAP-1): a column chunk may hold a leading dictionary page (GAP-3) followed by 1172 // one or more data pages. Walk pages from dataPageOffset until the chunk's numValues 1173 // level/value entries are consumed. 1174 int valuesToRead = (int)Math.min(cc.numValues(), numRows); 1175 var values = l(); 1176 List<Object> dictionary = null; 1177 int pageOff = (int)cc.dataPageOffset(); 1178 int consumed = 0; 1179 int pageGuard = 0; 1180 while (consumed < valuesToRead && pageOff < fileBytes.length) { 1181 if (++pageGuard > MAX_PAGES_PER_CHUNK) 1182 throw new ParseException("Column '%s' exceeds the maximum of %s pages per chunk", path, MAX_PAGES_PER_CHUNK); 1183 var ph = readPageHeader(fileBytes, pageOff, path, maxLength, maxCount); 1184 budget.charge(ph.uncompressedSize(), path); 1185 var compressedData = Arrays.copyOfRange(fileBytes, ph.bodyStart(), ph.bodyStart() + ph.compressedSize()); 1186 var decompressed = codec.decompress(compressedData, ph.uncompressedSize()); 1187 if (ph.pageType() == PAGE_DICTIONARY) { 1188 dictionary = readDictionaryPage(decompressed, ph.numValues(), cc.type(), trimStrings, isRawByteArrayColumn, isUuidColumn, logical); 1189 } else { 1190 int pageValues = Math.min(ph.numValues(), valuesToRead - consumed); 1191 boolean dictEncoded = ph.encoding() == ENCODING_PLAIN_DICTIONARY || ph.encoding() == ENCODING_RLE_DICTIONARY; 1192 readDataPageValues(decompressed, pageValues, maxDefLevel, defBitWidth, cc.type(), trimStrings, 1193 isRawByteArrayColumn, isUuidColumn, logical, dictEncoded, dictionary, values); 1194 consumed += pageValues; 1195 } 1196 pageOff = ph.bodyStart() + ph.compressedSize(); 1197 } 1198 if (parquetDebug()) 1199 parquetDebugLog("readColumnChunk values: path=" + path + " values=" + values); 1200 return values; 1201 } catch (IOException e) { 1202 throw new ParseException(e); 1203 } 1204 } 1205 1206 private static final int MAX_PAGES_PER_CHUNK = 1_000_000; 1207 1208 /** 1209 * Decodes one PLAIN-or-dictionary-encoded data page into {@code values}, appending {@link GroupNull} 1210 * sentinels for null intermediate OPTIONAL groups (GAP-14). 1211 */ 1212 static void readDataPageValues(byte[] decompressed, int pageValues, int maxDefLevel, int defBitWidth, 1213 int type, boolean trimStrings, boolean isRawByteArrayColumn, boolean isUuidColumn, ColumnLogical logical, 1214 boolean dictEncoded, List<Object> dictionary, List<Object> values) throws IOException, ParseException { 1215 if (dictEncoded) { 1216 // Dictionary-encoded data page (GAP-3): def levels (if any) then a 1-byte bit-width followed by 1217 // RLE/bit-packed dictionary indices. Nulls consume a def level but no index. 1218 readDictionaryEncodedPage(decompressed, pageValues, maxDefLevel, defBitWidth, dictionary, values); 1219 return; 1220 } 1221 var reader = new ParquetColumnReader(decompressed, pageValues, maxDefLevel, defBitWidth); 1222 while (reader.hasNext()) { 1223 var v = readValue(reader, type, trimStrings, isRawByteArrayColumn, isUuidColumn, logical); 1224 if (maxDefLevel >= 2 && reader.getDefLevel() < maxDefLevel - 1) 1225 values.add(new GroupNull(reader.getDefLevel())); 1226 else 1227 values.add(v); 1228 } 1229 } 1230 1231 /** 1232 * Decodes a {@code DICTIONARY_PAGE} body (PLAIN-encoded values, no def/rep levels) into an indexable 1233 * dictionary (GAP-3). 1234 * 1235 * @return The dictionary entries in index order. 1236 */ 1237 static List<Object> readDictionaryPage(byte[] decompressed, int numDictValues, int type, 1238 boolean trimStrings, boolean isRawByteArrayColumn, boolean isUuidColumn, ColumnLogical logical) throws IOException { 1239 // A dictionary page is PLAIN-encoded with no definition levels (every entry present): maxDefLevel=0. 1240 var reader = new ParquetColumnReader(decompressed, numDictValues, 0); 1241 var dict = new ArrayList<>(numDictValues); 1242 while (reader.hasNext()) 1243 dict.add(readValue(reader, type, trimStrings, isRawByteArrayColumn, isUuidColumn, logical)); 1244 return dict; 1245 } 1246 1247 /** 1248 * Decodes a dictionary-encoded data page (GAP-3): optional RLE definition levels, then a leading 1249 * 1-byte bit-width followed by RLE/bit-packed dictionary indices. Present values map their index 1250 * back through {@code dictionary}; nulls consume a definition level but no index. 1251 */ 1252 // Package-private for focused unit testing of the dictionary-index decode path (GAP-3). 1253 static void readDictionaryEncodedPage(byte[] decompressed, int pageValues, int maxDefLevel, int defBitWidth, 1254 List<Object> dictionary, List<Object> values) throws ParseException { 1255 if (dictionary == null) 1256 throw new ParseException("Dictionary-encoded data page encountered with no preceding dictionary page"); 1257 try { 1258 int off = 0; 1259 RleBitPackingDecoder defDecoder = null; 1260 if (maxDefLevel > 0 && decompressed.length >= 4) { 1261 int defLen = (decompressed[0] & 0xFF) | ((decompressed[1] & 0xFF) << 8) 1262 | ((decompressed[2] & 0xFF) << 16) | ((decompressed[3] & 0xFF) << 24); 1263 if (defLen >= 0 && defLen <= decompressed.length - 4) { 1264 off = 4; 1265 defDecoder = new RleBitPackingDecoder(decompressed, off, defLen, defBitWidth); 1266 off += defLen; 1267 } 1268 } 1269 // Dictionary indices section: 1-byte bit width, then RLE/bit-packed hybrid indices. 1270 int indexBitWidth = decompressed[off] & 0xFF; 1271 off++; 1272 var idxDecoder = new RleBitPackingDecoder(decompressed, off, decompressed.length - off, indexBitWidth); 1273 for (int i = 0; i < pageValues; i++) { 1274 int def = defDecoder != null ? defDecoder.readInt() : maxDefLevel; 1275 if (def < maxDefLevel) { 1276 if (maxDefLevel >= 2 && def < maxDefLevel - 1) 1277 values.add(new GroupNull(def)); 1278 else 1279 values.add(null); 1280 } else { 1281 int idx = idxDecoder.readInt(); 1282 if (idx >= dictionary.size()) 1283 throw new ParseException("Dictionary index %s out of range [0,%s)", idx, dictionary.size()); 1284 values.add(dictionary.get(idx)); 1285 } 1286 } 1287 } catch (IOException e) { 1288 throw new ParseException(e); 1289 } 1290 } 1291 1292 private static Object readValue(ParquetColumnReader reader, int type, boolean trimStrings) throws IOException { 1293 // List/map element columns: no per-path discriminator is threaded, so keep the legacy 1294 // "FIXED_LEN_BYTE_ARRAY == UUID" assumption (isUuidColumn=true) to preserve existing round trips. 1295 return readValue(reader, type, trimStrings, false, true, null); 1296 } 1297 1298 // Package-private for focused unit testing of the external-read value-decode paths (INT96/DECIMAL/TIME/etc.). 1299 static Object readValue(ParquetColumnReader reader, int type, boolean trimStrings, boolean isRawByteArrayColumn, boolean isUuidColumn, ColumnLogical logical) throws IOException { 1300 reader.advance(); 1301 if (reader.isNull()) 1302 return null; 1303 // Native logical-type decode (GAP-9/10): when a column carries a DECIMAL/DATE/TIME/TIMESTAMP-micros 1304 // convertedType, decode to the matching value and surface a form the framework's coercion understands. 1305 // Read-side decode is always on; absent a logical convertedType the physical-type switch below applies. 1306 if (logical != null) { 1307 var nativeValue = readLogicalValue(reader, type, logical); 1308 if (nativeValue != NOT_LOGICAL) 1309 return nativeValue; 1310 } 1311 return switch (type) { 1312 case TYPE_BOOLEAN -> reader.readBoolean(); 1313 case TYPE_INT32 -> reader.readInt32(); 1314 case TYPE_INT64 -> reader.readInt64(); 1315 // INT96 (GAP-8): legacy 12-byte timestamp (Impala/Hive/older Spark). Decode to an Instant and 1316 // surface its ISO-8601 string so the framework's temporal coercion reconstructs the target type. 1317 // Read-only — Juneau never writes INT96. 1318 case TYPE_INT96 -> reader.readInt96AsInstant().toString(); 1319 case TYPE_FLOAT -> reader.readFloat(); 1320 case TYPE_DOUBLE -> reader.readDouble(); 1321 case TYPE_BYTE_ARRAY -> { 1322 // When the schema marks this column as a raw byte[] column 1323 // (TYPE_BYTE_ARRAY with no convertedType — see SchemaReadResult), surface the bytes 1324 // as a byte[] so the framework can hand them straight to the bean-property setter. 1325 // Otherwise read the bytes as UTF-8 for string / enum / date / duration / decimal 1326 // columns (which carry CONVERTED_UTF8 / CONVERTED_ENUM / CONVERTED_TIMESTAMP_MILLIS). 1327 if (isRawByteArrayColumn) { 1328 yield reader.readByteArray(); 1329 } 1330 var s = reader.readByteArrayAsString(); 1331 yield trimStrings ? s.trim() : s; 1332 } 1333 // TYPE_FIXED_LEN_BYTE_ARRAY is emitted by ParquetSchemaBuilder for UUID columns 1334 // (TYPE_FIXED_LEN_BYTE_ARRAY(16) / LOGICAL_TYPE_UUID). Routing prefers the explicit 1335 // LogicalType discriminant when the writer emitted it (opt-in mode), and otherwise falls 1336 // back to the legacy "FLBA == UUID" physical-type assumption — both resolve to isUuidColumn 1337 // upstream (see readSchema). Without the UUID conversion the parser would surface a raw 1338 // byte[16] into the row JsonMap and the framework's generic String/byte[]→UUID coercion 1339 // path can't reassemble it back to a UUID. 1340 case TYPE_FIXED_LEN_BYTE_ARRAY -> { 1341 var bytes = reader.readFixedLenByteArray(16); 1342 // A FLBA column with an explicit non-UUID logical type is surfaced as raw bytes rather than 1343 // misread as a UUID. Juneau's own writer never emits such a column today, so this is a 1344 // forward-compat guard for future FLBA logical types. // HTT 1345 yield isUuidColumn ? uuidFromFixedLenBytes(bytes) : bytes; 1346 } 1347 default -> { 1348 var s = reader.readByteArrayAsString(); 1349 yield trimStrings ? s.trim() : s; 1350 } 1351 }; 1352 } 1353 1354 /** Sentinel returned by {@link #readLogicalValue} when the column is not a recognized native logical type. */ 1355 private static final Object NOT_LOGICAL = new Object(); 1356 1357 /** 1358 * Decodes a native logical-type value (GAP-9/10) based on the column's {@code convertedType} and physical 1359 * type. Returns {@link #NOT_LOGICAL} if the convertedType is not one this decoder handles (so the caller 1360 * falls back to the physical-type switch). The reader has already been advanced and confirmed non-null. 1361 * 1362 * <ul> 1363 * <li>DECIMAL (INT32/INT64) → {@link java.math.BigDecimal} with the schema scale. 1364 * <li>DATE (INT32 days-since-epoch) → {@link java.time.LocalDate} ISO string. 1365 * <li>TIME_MILLIS / TIME_MICROS → {@link java.time.LocalTime} ISO string. 1366 * <li>TIMESTAMP_MICROS (INT64 micros-since-epoch) → {@link java.time.Instant} ISO string. 1367 * </ul> 1368 */ 1369 // Package-private for focused unit testing of the native logical-type decode paths (GAP-9/10). 1370 static Object readLogicalValue(ParquetColumnReader reader, int type, ColumnLogical logical) throws IOException { 1371 int ct = logical.convertedType(); 1372 switch (ct) { 1373 case CONVERTED_DECIMAL -> { 1374 // DECIMAL backed by INT32 or INT64: unscaled integer * 10^-scale. BYTE_ARRAY/FLBA-backed 1375 // DECIMAL is left to the string path (not commonly emitted by Juneau). 1376 long unscaled; 1377 if (type == TYPE_INT32) 1378 unscaled = reader.readInt32(); 1379 else if (type == TYPE_INT64) 1380 unscaled = reader.readInt64(); 1381 else 1382 return NOT_LOGICAL; 1383 return java.math.BigDecimal.valueOf(unscaled, logical.scale()); 1384 } 1385 case CONVERTED_DATE -> { 1386 if (type != TYPE_INT32) 1387 return NOT_LOGICAL; 1388 return java.time.LocalDate.ofEpochDay(reader.readInt32()).toString(); 1389 } 1390 case CONVERTED_TIME_MILLIS -> { 1391 if (type != TYPE_INT32) 1392 return NOT_LOGICAL; 1393 return java.time.LocalTime.ofNanoOfDay(reader.readInt32() * 1_000_000L).toString(); 1394 } 1395 case CONVERTED_TIME_MICROS -> { 1396 if (type != TYPE_INT64) 1397 return NOT_LOGICAL; 1398 return java.time.LocalTime.ofNanoOfDay(reader.readInt64() * 1_000L).toString(); 1399 } 1400 case CONVERTED_TIMESTAMP_MICROS -> { 1401 if (type != TYPE_INT64) 1402 return NOT_LOGICAL; 1403 long micros = reader.readInt64(); 1404 long secs = Math.floorDiv(micros, 1_000_000L); 1405 long microOfSec = Math.floorMod(micros, 1_000_000L); 1406 return java.time.Instant.ofEpochSecond(secs, microOfSec * 1_000L).toString(); 1407 } 1408 default -> { 1409 return NOT_LOGICAL; 1410 } 1411 } 1412 } 1413 1414 /** 1415 * Reassembles a {@link UUID} from the 16-byte big-endian wire form written by 1416 * {@code ParquetSerializerSession#toFixedLenByteArray(UUID)}. 1417 * 1418 * <p> 1419 * Mirrors the serializer's write loop (most-significant 8 bytes then least-significant 8 bytes, 1420 * both big-endian) so the round-trip is bit-exact. Falls back to {@code new UUID(0L, 0L)} when 1421 * the buffer is the wrong length — defensive guard for fuzzed input. 1422 */ 1423 // Package-private for focused unit testing of the null/wrong-length guard. 1424 static UUID uuidFromFixedLenBytesForTest(byte[] b) { 1425 return uuidFromFixedLenBytes(b); 1426 } 1427 1428 private static UUID uuidFromFixedLenBytes(byte[] b) { 1429 if (b == null || b.length != 16) 1430 return new UUID(0L, 0L); 1431 long msb = 0L; 1432 long lsb = 0L; 1433 for (int i = 0; i < 8; i++) 1434 msb = (msb << 8) | (b[i] & 0xFFL); 1435 for (int i = 0; i < 8; i++) 1436 lsb = (lsb << 8) | (b[8 + i] & 0xFFL); 1437 return new UUID(msb, lsb); 1438 } 1439 1440 private List<Object> reassembleRows(Map<String,List<Object>> columnData, int numRows, ClassMeta<?> elementType) throws ParseException { 1441 var result = new ArrayList<>(clampCapacity(numRows, effectiveMaxCount())); 1442 var listBeanColumns = groupListBeanColumns(columnData.keySet(), elementType); 1443 var mapColumns = groupMapColumns(columnData.keySet()); 1444 for (int i = 0; i < numRows; i++) { 1445 var row = new JsonMap(); 1446 for (var e : columnData.entrySet()) { 1447 var fullPath = e.getKey(); 1448 var rowRelPath = rowRelativePath(fullPath); 1449 var listProp = listPropertyPath(rowRelPath); 1450 if (listBeanColumns.containsKey(listProp) || isMapKeyValueColumnPath(rowRelPath)) 1451 continue; 1452 var values = e.getValue(); 1453 var v = values.get(i); 1454 if (v instanceof GroupNull v2) { 1455 // Null intermediate group: set the prefix path (up to the null level) to null without 1456 // synthesizing the deeper map structure (GAP-14). 1457 var parts = rowRelPath.split("\\."); 1458 var lvl = Math.min(v2.defLevel(), parts.length - 1); 1459 var prefix = String.join(".", Arrays.copyOfRange(parts, 0, lvl + 1)); 1460 if (isTrimStrings()) 1461 prefix = prefix.trim(); 1462 setByPath(row, prefix, null); 1463 } else { 1464 var path = isListColumnPath(fullPath) ? listProp : rowRelPath; 1465 if (isTrimStrings()) 1466 path = path.trim(); 1467 setByPath(row, path, v); 1468 } 1469 } 1470 for (var e : listBeanColumns.entrySet()) { 1471 var listProp = e.getKey(); 1472 var columnsInGroup = e.getValue(); 1473 var mergedList = mergeListBeanColumns(columnsInGroup, columnData, i); 1474 setByPath(row, listProp, mergedList); 1475 } 1476 for (var e : mapColumns.entrySet()) { 1477 var mapProp = e.getKey(); 1478 var columnsInGroup = e.getValue(); 1479 var mergedMap = mergeMapColumns(columnsInGroup, columnData, i); 1480 setByPath(row, mapProp, mergedMap); 1481 } 1482 replaceNullKeySentinel(row); 1483 if (elementType.isBean()) 1484 collapseOptionalWrappers(row, elementType); 1485 Object rowOrBean = elementType.isBean() ? convertToType(prepareMapForBean(row, elementType), elementType) : row; 1486 result.add(rowOrBean); 1487 } 1488 return result; 1489 } 1490 1491 private static Map<String,List<ListColumnInfo>> groupListBeanColumns(Set<String> columnPaths, ClassMeta<?> elementType) { 1492 var groups = new LinkedHashMap<String,List<ListColumnInfo>>(); 1493 if (!elementType.isBean()) 1494 return groups; 1495 var bm = elementType.getBeanMeta(); 1496 for (var fullPath : columnPaths) { 1497 if (!isListColumnPath(fullPath)) 1498 continue; 1499 var rowRelPath = rowRelativePath(fullPath); 1500 var listProp = listPropertyPath(rowRelPath); 1501 var suffix = ".list.element."; 1502 var suffixStart = listProp.length() + suffix.length(); 1503 var pMeta = rowRelPath.length() > suffixStart ? bm.getPropertyMeta(listProp) : null; 1504 var propClassMeta = pMeta != null ? (ClassMeta<?>) pMeta.getBeanInfo() : null; 1505 var elemType = propClassMeta != null && (propClassMeta.isCollection() || propClassMeta.isArray()) ? propClassMeta.getElementType() : null; 1506 if (elemType != null && elemType.isBean()) { 1507 var elementProp = rowRelPath.substring(suffixStart); 1508 groups.computeIfAbsent(listProp, k -> l()).add(new ListColumnInfo(fullPath, elementProp, elemType)); 1509 } 1510 } 1511 return groups; 1512 } 1513 1514 private record ListColumnInfo(String fullPath, String elementProp, ClassMeta<?> elementType) {} 1515 1516 private record MapColumnInfo(String keyColumnPath, String valueColumnPath) {} 1517 1518 private static Map<String,MapColumnInfo> groupMapColumns(Set<String> columnPaths) { 1519 var groups = new LinkedHashMap<String,MapColumnInfo>(); 1520 for (var fullPath : columnPaths) { 1521 var rowRelPath = rowRelativePath(fullPath); 1522 if (!isMapKeyValueColumnPath(rowRelPath)) 1523 continue; 1524 var prop = mapPropertyPath(rowRelPath); 1525 if (rowRelPath.endsWith(".key_value.key")) 1526 groups.put(prop, new MapColumnInfo(fullPath, null)); 1527 else if (rowRelPath.endsWith(".key_value.value")) { 1528 var prev = groups.get(prop); 1529 groups.put(prop, new MapColumnInfo(prev.keyColumnPath(), fullPath)); 1530 } 1531 } 1532 return groups; 1533 } 1534 1535 private Map<Object,Object> mergeMapColumns(MapColumnInfo info, Map<String,List<Object>> columnData, int rowIndex) throws ParseException { 1536 var keyList = (List<?>) columnData.get(info.keyColumnPath()).get(rowIndex); 1537 if (keyList.isEmpty()) 1538 return m(); 1539 var valueList = (List<?>) columnData.get(info.valueColumnPath()).get(rowIndex); 1540 var result = m(); 1541 var valueIter = valueList.iterator(); 1542 for (var k : keyList) { 1543 var v = valueIter.next(); 1544 Object key = ctx.nullKeyString.equals(String.valueOf(k)) ? null : k; 1545 result.put(key, v); 1546 } 1547 return result; 1548 } 1549 1550 private List<Object> mergeListBeanColumns(List<ListColumnInfo> columnsInGroup, Map<String,List<Object>> columnData, 1551 int rowIndex) throws ParseException { 1552 var elemType = columnsInGroup.get(0).elementType(); 1553 var firstList = (List<?>) columnData.get(columnsInGroup.get(0).fullPath()).get(rowIndex); 1554 int listSize = firstList.size(); 1555 var result = new ArrayList<>(listSize); 1556 for (int j = 0; j < listSize; j++) { 1557 var elemMap = new JsonMap(); 1558 for (var col : columnsInGroup) { 1559 var rowList = (List<?>) columnData.get(col.fullPath()).get(rowIndex); 1560 elemMap.put(col.elementProp(), rowList.get(j)); 1561 } 1562 result.add(convertToType(prepareMapForBean(elemMap, elemType), elemType)); 1563 } 1564 return result; 1565 } 1566 1567 /** 1568 * Replaces keys equal to {@link ParquetParser#nullKeyString} (default <js>"<NULL>"</js>) 1569 * with actual <jk>null</jk> in maps. Enables round-trip of maps with null keys in flat (column-per-key) format. 1570 */ 1571 private void replaceNullKeySentinel(Object obj) { 1572 if (obj == null) 1573 return; 1574 if (obj instanceof Map<?,?> obj2) { 1575 if (obj2.containsKey(ctx.nullKeyString)) { 1576 var val = obj2.get(ctx.nullKeyString); 1577 ((Map<Object,Object>)obj2).remove(ctx.nullKeyString); 1578 ((Map<Object,Object>)obj2).put(null, val); 1579 } 1580 for (var v : obj2.values()) 1581 replaceNullKeySentinel(v); 1582 } else if (obj instanceof List<?> obj2) { 1583 for (var e : obj2) 1584 replaceNullKeySentinel(e); 1585 } 1586 } 1587 1588 /** Collapses Parquet optional-group {value: X} wrappers so MarshallingSession receives unwrapped values for Optional properties. */ 1589 private void collapseOptionalWrappers(Map<?,?> row, ClassMeta<?> elementType) { 1590 var bm = elementType.getBeanMeta(); 1591 boolean hasOptional = bm.getProperties().values().stream().anyMatch(p -> p.getBeanInfo() != null && p.getBeanInfo().isOptional()); 1592 if (!hasOptional) 1593 return; 1594 for (var pMeta : bm.getProperties().values()) { 1595 var name = pMeta.getName(); 1596 var val = row.get(name); 1597 var propType = (ClassMeta<?>) pMeta.getBeanInfo(); 1598 if (val == null) 1599 continue; 1600 if (propType.isOptional() && val instanceof Map<?,?> val2) { 1601 var elemType = propType.getElementType(); 1602 boolean absent = isAbsentOptionalMap(val2, elemType); 1603 // Detect absent inner Optional: all-null map, empty map, or nested {value:{all-null}} structure. 1604 // Use null so MarshallingSession's null→Optional<Optional<X>> conversion produces Optional.of(Optional.empty()). 1605 if (absent) { 1606 ((Map<String,Object>)row).put(name, null); 1607 } else { 1608 var inner = val2.get("value"); 1609 var collapsed = collapseOptionalValue(inner, elemType); 1610 ((Map<String,Object>)row).put(name, collapsed); 1611 } 1612 } else if (propType.isBean() && val instanceof Map<?,?> val2) 1613 collapseOptionalWrappers(val2, propType); 1614 } 1615 } 1616 1617 /** 1618 * Returns true if the map represents an absent Optional value that should be treated as null, 1619 * allowing MarshallingSession's null→Optional<Optional<X>> conversion to produce Optional.of(Optional.empty()). 1620 * 1621 * <p>Absent patterns: 1622 * <ul> 1623 * <li>Empty map {} 1624 * <li>Map whose values are all null (e.g. {value: null}) 1625 * <li>Map {value: M} where M is itself absent (recursively): handles Optional<Optional<X>> 1626 * <li>Map {value: M} where M has all null leaves (bean group for absent inner type) 1627 * </ul> 1628 */ 1629 private boolean isAbsentOptionalMap(Map<?,?> m, ClassMeta<?> elemType) { 1630 if (m.values().stream().allMatch(v -> v == null)) 1631 return true; 1632 var inner = m.get("value"); 1633 if (inner instanceof Map<?,?> inner2) { 1634 if (elemType.isOptional()) 1635 return isAbsentOptionalMap(inner2, elemType.getElementType()); 1636 // For bean (non-Optional) inner type: check if all nested leaf values are null (absent bean group) 1637 return allNullLeaves(inner2); 1638 } 1639 return false; 1640 } 1641 1642 /** Returns true if all leaf values in the nested map structure are null. */ 1643 private boolean allNullLeaves(Object obj) { 1644 if (obj == null) 1645 return true; 1646 if (!(obj instanceof Map<?,?> obj2)) 1647 return false; 1648 return obj2.values().stream().allMatch(this::allNullLeaves); 1649 } 1650 1651 /** Recursively unwraps {value: X} when target is Optional; collapses nested beans. */ 1652 private Object collapseOptionalValue(Object val, ClassMeta<?> targetType) { 1653 if (targetType.isOptional() && val instanceof Map<?,?> val2) { 1654 if (isAbsentOptionalMap(val2, targetType)) 1655 return null; // Let MarshallingSession convert null to Optional.of(Optional.empty()) 1656 return collapseOptionalValue(val2.get("value"), targetType.getElementType()); 1657 } 1658 if (targetType.isBean() && val instanceof Map<?,?> val2) { 1659 collapseOptionalWrappers(val2, targetType); 1660 return val2; 1661 } 1662 return val; 1663 } 1664 1665 private static void setByPath(Map<String,Object> target, String path, Object value) throws ParseException { 1666 var parts = path.split("\\."); 1667 if (parts.length == 1) { 1668 target.put(path, value); 1669 return; 1670 } 1671 Object current = target; 1672 for (int i = 0; i < parts.length - 1; i++) { 1673 var key = parts[i]; 1674 var currentMap = (Map<?,?>) current; 1675 current = ((Map<String,Object>)currentMap).computeIfAbsent(key, k -> new JsonMap()); 1676 } 1677 ((Map<String,Object>)current).put(parts[parts.length - 1], value); 1678 } 1679 1680 /** Wraps Map in JsonMap when target expects JsonMap (e.g. bean with constructor A(JsonMap)). */ 1681 private static Object prepareMapForBean(Object value, ClassMeta<?> type) { 1682 if (value == null) 1683 return value; 1684 if (value instanceof JsonMap) 1685 return value; 1686 if (value instanceof Map<?,?> value2 && !type.isMap()) 1687 return new JsonMap(value2); 1688 return value; 1689 } 1690 1691 /** Unwraps ValueHolder map {value: X} to X when target is scalar; else converts via MarshallingSession. */ 1692 private Object unwrapValueHolder(Object row, ClassMeta<?> targetType) throws ParseException { 1693 if (!(row instanceof Map<?,?> row2)) 1694 return convertToType(row, targetType); 1695 // Unwrap {value: X} when target is scalar (or Object) - allow maps with extra keys (e.g. _type) 1696 if (row2.containsKey("value") && (targetType.isObject() 1697 || (!targetType.isMap() && !targetType.isCollection() && !targetType.isArray()))) { 1698 var v = row2.get("value"); 1699 if (targetType.isObject()) 1700 return v; 1701 return convertToType(prepareMapForBean(v, targetType), targetType); 1702 } 1703 return convertToType(prepareMapForBean(row, targetType), targetType); 1704 } 1705 1706 private Object toArray(List<Object> values, ClassMeta<?> componentType) throws ParseException { 1707 var ct = componentType.inner(); 1708 var arr = Array.newInstance(ct, values.size()); 1709 for (int i = 0; i < values.size(); i++) 1710 Array.set(arr, i, convertToType(values.get(i), componentType)); 1711 return arr; 1712 } 1713 1714 private Object toCollection(List<Object> values, ClassMeta<?> collectionType) throws ParseException { 1715 var elemType = collectionType.getElementType(); 1716 Collection<Object> result; 1717 try { 1718 result = collectionType.canCreateNewInstance() ? (Collection<Object>)collectionType.newInstance() : null; 1719 } catch (@SuppressWarnings("unused") Exception ignored) { 1720 result = null; 1721 } 1722 if (result == null) 1723 result = l(); 1724 for (var v : values) 1725 result.add((Object)convertToType(v, elemType)); 1726 return result; 1727 } 1728} 1729