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 &le; 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 &le; 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> &mdash; 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 &ge; 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 &lt; k &lt; 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 &le; k &le; N)
971    *   <li>def=2*(k-1)+1 — empty list at level k (1 &le; k &le; 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>"&lt;NULL&gt;"</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