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