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.time.*;
023import java.util.*;
024import java.util.stream.*;
025
026import org.apache.juneau.commons.bean.*;
027import org.apache.juneau.commons.reflect.*;
028import org.apache.juneau.marshall.*;
029import org.apache.juneau.marshall.serializer.*;
030
031/**
032 * Builds Parquet schema (list of SchemaElement) from Juneau bean ClassMeta.
033 *
034 * <p>
035 * Maps Java types to Parquet physical and logical types per the
036 * <a class="doclink" href="https://parquet.apache.org/docs/file-format/">Parquet specification</a>.
037 */
038@SuppressWarnings({
039   "java:S1192", // Duplicated "value" is Parquet Optional key; constant would obscure schema mapping
040   "java:S3776" // addLeafSchema() and addBeanSchema() map every Java type to a Parquet physical/logical type in one pass; splitting them would obscure the mapping
041})
042public final class ParquetSchemaBuilder {
043
044   private final MarshallingContext marshallingContext;
045   private final boolean writeDatesAsTimestamp;
046   private final ParquetCycleHandling cycleHandling;
047   private final int maxRecursionDepth;
048   private final boolean nativeLogicalTypes;
049
050   /** Fixed scale/precision for INT64-backed native DECIMAL emission (GAP-9). 9 fractional digits, 18 total. */
051   static final int DECIMAL_SCALE = 9;
052   static final int DECIMAL_PRECISION = 18;
053
054   /**
055    * Constructor.
056    *
057    * @param marshallingContext The bean context for type resolution.
058    * @param writeDatesAsTimestamp If true, Date/Calendar/Temporal map to INT64+TIMESTAMP_MILLIS; else to BYTE_ARRAY+STRING.
059    * @param cycleHandling How to handle structural cycles when building schema.
060    */
061   public ParquetSchemaBuilder(MarshallingContext marshallingContext, boolean writeDatesAsTimestamp, ParquetCycleHandling cycleHandling) {
062      this(marshallingContext, writeDatesAsTimestamp, cycleHandling, 5, false);
063   }
064
065   /**
066    * Constructor.
067    *
068    * @param marshallingContext The bean context for type resolution.
069    * @param writeDatesAsTimestamp If true, Date/Calendar/Temporal map to INT64+TIMESTAMP_MILLIS; else to BYTE_ARRAY+STRING.
070    * @param cycleHandling How to handle structural cycles when building schema.
071    * @param maxRecursionDepth Maximum expansion depth for self-referential bean types (GAP-13).
072    */
073   public ParquetSchemaBuilder(MarshallingContext marshallingContext, boolean writeDatesAsTimestamp, ParquetCycleHandling cycleHandling, int maxRecursionDepth) {
074      this(marshallingContext, writeDatesAsTimestamp, cycleHandling, maxRecursionDepth, false);
075   }
076
077   /**
078    * Constructor.
079    *
080    * @param marshallingContext The bean context for type resolution.
081    * @param writeDatesAsTimestamp If true, Date/Calendar/Temporal map to INT64+TIMESTAMP_MILLIS; else to BYTE_ARRAY+STRING.
082    * @param cycleHandling How to handle structural cycles when building schema.
083    * @param maxRecursionDepth Maximum expansion depth for self-referential bean types (GAP-13).
084    * @param nativeLogicalTypes If true, emit binary-native DECIMAL/DATE/TIME/TIMESTAMP-micros logical types
085    *    instead of the default string / TIMESTAMP-millis normalization (GAP-9/10).
086    */
087   public ParquetSchemaBuilder(MarshallingContext marshallingContext, boolean writeDatesAsTimestamp, ParquetCycleHandling cycleHandling, int maxRecursionDepth, boolean nativeLogicalTypes) {
088      this.marshallingContext = marshallingContext;
089      this.writeDatesAsTimestamp = writeDatesAsTimestamp;
090      this.cycleHandling = or(cycleHandling, ParquetCycleHandling.NULL);
091      this.maxRecursionDepth = Math.max(1, maxRecursionDepth);
092      this.nativeLogicalTypes = nativeLogicalTypes;
093   }
094
095   /**
096    * Builds the Parquet schema for the given bean class.
097    *
098    * @param cm The bean class metadata.
099    * @return The depth-first list of schema elements (root first).
100    */
101   public List<ParquetSchemaElement> buildSchema(ClassMeta<?> cm) {
102      return buildSchema(cm, null);
103   }
104
105   /**
106    * Builds the Parquet schema, using a sample bean to resolve collection element types when generics are erased.
107    *
108    * @param cm The bean class metadata.
109    * @param sampleBean Optional sample instance; when provided, collection element types are resolved from actual values.
110    * @return The depth-first list of schema elements (root first).
111    */
112   public List<ParquetSchemaElement> buildSchema(ClassMeta<?> cm, Object sampleBean) {
113      var elements = new ArrayList<ParquetSchemaElement>();
114      var typesInProgress = new HashMap<Class<?>,Integer>();
115      addSchemaElements(elements, cm, "root", null, true, sampleBean, typesInProgress);
116      return elements;
117   }
118
119   /**
120    * Builds the Parquet schema for a Map root (single row, columns from map keys).
121    *
122    * @param map The map instance (keys become column names).
123    * @return The depth-first list of schema elements (root first).
124    */
125   public List<ParquetSchemaElement> buildSchemaFromMap(Map<?,?> map) {
126      var elements = new ArrayList<ParquetSchemaElement>();
127      var keys = map.keySet().stream()
128         .filter(String.class::isInstance)
129         .map(k -> (String) k)
130         .sorted()
131         .toList();
132      elements.add(new ParquetSchemaElement(
133         "root",
134         null,
135         null,
136         null,
137         keys.size(),
138         null,
139         null,
140         null,
141         null,
142         null));
143      var typesInProgress = new HashMap<Class<?>,Integer>();
144      for (var key : keys) {
145         var val = map.get(key);
146         Class<?> valClass = val != null ? val.getClass() : Object.class;
147         var cm = marshallingContext.getClassMeta(valClass);
148         addSchemaElements(elements, cm, key, "root", false, null, typesInProgress);
149      }
150      return elements;
151   }
152
153   /**
154    * Builds the Parquet schema for a Map root with non-String keys (flat list-of-pairs: root.key, root.value).
155    *
156    * <p>
157    * Each map entry becomes one Parquet row. Keys and values use native Parquet types.
158    *
159    * @param keySample Sample key for type inference.
160    * @param valueSample Sample value for type inference.
161    * @return The depth-first list of schema elements (root first).
162    */
163   public List<ParquetSchemaElement> buildSchemaForKeyValuePairs(Object keySample, Object valueSample) {
164      var elements = new ArrayList<ParquetSchemaElement>();
165      var typesInProgress = new HashMap<Class<?>,Integer>();
166      Class<?> keyClass = keySample != null ? keySample.getClass() : Object.class;
167      Class<?> valueClass = valueSample != null ? valueSample.getClass() : Object.class;
168      var keyCm = marshallingContext.getClassMeta(keyClass);
169      var valueCm = marshallingContext.getClassMeta(valueClass);
170      elements.add(new ParquetSchemaElement("root", null, null, null, 2, null, null, null, null, null));
171      addSchemaElements(elements, keyCm, "key", "root", false, null, typesInProgress);
172      addSchemaElements(elements, valueCm, "value", "root", false, null, typesInProgress);
173      return elements;
174   }
175
176   private void addSchemaElements(List<ParquetSchemaElement> elements, ClassMeta<?> cm, String name, String parentPath, boolean isRoot, Object sampleBean, Map<Class<?>,Integer> typesInProgress) {
177      if (cm.isOptional()) {
178         addOptionalSchema(elements, cm, name, parentPath, isRoot, sampleBean, typesInProgress);
179      } else if (cm.isBean()) {
180         addBeanSchema(elements, cm, name, parentPath, isRoot, sampleBean, typesInProgress);
181      } else if (cm.isByteArray()) {
182         addLeafSchema(elements, cm, name, parentPath, isRoot);
183      } else if (cm.isCollection() || cm.isArray()) {
184         addListSchema(elements, cm, name, parentPath, isRoot, sampleBean, typesInProgress);
185      } else if (cm.isMap()) {
186         addMapSchema(elements, cm, name, parentPath, isRoot, typesInProgress);
187      } else {
188         addLeafSchema(elements, cm, name, parentPath, isRoot);
189      }
190   }
191
192   /** Optional&lt;X&gt; → optional group with "value" child of type X (2.2). */
193   private void addOptionalSchema(List<ParquetSchemaElement> elements, ClassMeta<?> cm, String name, String parentPath, boolean isRoot, Object sampleBean, Map<Class<?>,Integer> typesInProgress) {
194      var et = cm.getElementType();
195      Object innerSample = sampleBean instanceof Optional<?> sampleBean2 ? sampleBean2.orElse(null) : sampleBean;
196      var optPath = parentPath != null ? parentPath + "." + name : name;
197      elements.add(new ParquetSchemaElement(name, null, null, isRoot ? null : OPTIONAL, 1, null, null, null, null, optPath));
198      addSchemaElements(elements, et, "value", optPath, false, innerSample, typesInProgress);
199   }
200
201   private void addBeanSchema(List<ParquetSchemaElement> elements, ClassMeta<?> cm, String name, String parentPath, boolean isRoot, Object sampleBean, Map<Class<?>,Integer> typesInProgress) {
202      var bm = cm.getBeanMeta();
203      var beanClass = cm.inner();
204      // Recursion reached here indirectly (e.g. through Optional/collection of the same type) at the depth
205      // limit: keep the legacy String back-reference placeholder so the enclosing group's child count stays
206      // consistent.  Direct bean-property recursion is handled by the per-property filter below (truncated
207      // by omission, which round-trips as null).
208      if (typesInProgress.getOrDefault(beanClass, 0) >= maxRecursionDepth) {
209         if (cycleHandling == ParquetCycleHandling.THROW) {
210            var path = parentPath + "." + name;
211            throw new SerializeException("Cyclic type reference at '%s' (type '%s'). Use @ParentProperty to exclude back-references or set cycleHandling(NULL).", path, beanClass.getName());
212         }
213         addLeafSchema(elements, marshallingContext.getClassMeta(String.class), name, parentPath, isRoot);
214         return;
215      }
216      typesInProgress.merge(beanClass, 1, Integer::sum);
217      try {
218         var ap = marshallingContext.getAnnotationProvider();
219         var props = bm.getProperties().values().stream()
220            .filter(BeanPropertyMeta::canRead)
221            .filter(p -> !isParentProperty(ap, p))
222            .toList();
223         var childParentPath = parentPath != null ? parentPath + "." + name : name;
224         // Resolve each property (incl. Object->sample inference) and drop direct recursive bean properties
225         // that would exceed the depth limit (GAP-13).  Omitting them keeps the schema acyclic; on read the
226         // missing column reconstructs as null (documented-lossy beyond the limit) instead of a toString.
227         var entries = new ArrayList<ResolvedProp>();
228         for (var p : props) {
229            Object childSample = null;
230            if (sampleBean instanceof Map<?,?> sampleBean2)
231               childSample = sampleBean2.get(p.getName());
232            ClassMeta<?> propCm = (ClassMeta<?>) p.getBeanInfo();
233            // Native logical types (GAP-9/10): a java.time temporal property carries a swap, so its
234            // resolved ClassMeta is an Object/String surrogate that hides the concrete type.  Recover the
235            // declared field/getter type so the DATE/TIME/TIMESTAMP-micros leaf branches can fire.  Only
236            // overrides when the declared type is one of the native logical types we emit.
237            if (nativeLogicalTypes) {
238               var declared = declaredNativeType(p);
239               if (declared != null)
240                  propCm = marshallingContext.getClassMeta(declared);
241            }
242            // When property type is Object, infer from sample so Map/Collection get proper schema (2.2)
243            if ((propCm == null || propCm.isObject()) && childSample != null) // HTT: propCm is non-null (getBeanInfo always non-null for bean properties)
244               propCm = marshallingContext.getClassMeta(childSample.getClass());
245            // When property type is the abstract Number, re-type from a concrete numeric sample so
246            // the column uses the actual physical width (INT32/INT64/DOUBLE) and fractional values survive
247            // (GAP-12).  Only fires when the sample is itself a Number — a swapped property whose raw sample
248            // is non-numeric stays on the lossless INT64 default.
249            else if (propCm != null && propCm.is(Number.class) && childSample instanceof Number) // HTT: propCm is non-null (same invariant as above)
250               propCm = marshallingContext.getClassMeta(childSample.getClass());
251            if (propCm != null && propCm.isBean() && typesInProgress.getOrDefault(propCm.inner(), 0) >= maxRecursionDepth) { // HTT: propCm is non-null
252               if (cycleHandling == ParquetCycleHandling.THROW)
253                  throw new SerializeException("Cyclic type reference at '%s.%s' (type '%s'). Use @ParentProperty to exclude back-references or set cycleHandling(NULL).", childParentPath, p.getName(), propCm.inner().getName());
254               continue;
255            }
256            entries.add(new ResolvedProp(p.getName(), propCm, childSample));
257         }
258         elements.add(new ParquetSchemaElement(
259            name,
260            null,
261            null,
262            isRoot ? null : OPTIONAL,
263            entries.size(),
264            null,
265            null,
266            null,
267            null,
268            childParentPath));
269         for (var e : entries)
270            addSchemaElements(elements, e.cm, e.name, childParentPath, false, e.sample, typesInProgress);
271      } finally {
272         typesInProgress.merge(beanClass, -1, (a, b) -> a + b == 0 ? null : a + b);
273      }
274   }
275
276   private record ResolvedProp(String name, ClassMeta<?> cm, Object sample) {}
277
278   /**
279    * Returns the property's declared field/getter class when it is a binary-native logical type
280    * (java.time temporal, {@link java.math.BigDecimal}, or {@link java.math.BigInteger}); otherwise
281    * <jk>null</jk>.  Used to look past a registered swap whose resolved ClassMeta hides the concrete
282    * type, so the native logical-type leaf branches in {@link #addLeafSchema} can fire (GAP-9/10).
283    */
284   private static Class<?> declaredNativeType(BeanPropertyMeta p) {
285      // Resolve the declared class from the field (preferred) or, for getter-only properties, the getter.
286      var f = p.getField();
287      Class<?> c = f != null ? f.getFieldType().inner() : p.getGetter().getReturnType().inner();
288      if (c == LocalDate.class || c == LocalTime.class || c == OffsetTime.class
289         || c == java.math.BigDecimal.class || c == java.math.BigInteger.class
290         || Date.class.isAssignableFrom(c) || Calendar.class.isAssignableFrom(c)
291         || java.time.temporal.Temporal.class.isAssignableFrom(c))
292         return c;
293      return null;
294   }
295
296   private static boolean isParentProperty(AnnotationProvider ap, BeanPropertyMeta p) {
297      var g = p.getGetter();
298      if (g != null && ap.has(ParentProperty.class, g))
299         return true;
300      var s = p.getSetter();
301      if (s != null && ap.has(ParentProperty.class, s))
302         return true;
303      var f = p.getField();
304      return f != null && ap.has(ParentProperty.class, f);
305   }
306
307   private void addListSchema(List<ParquetSchemaElement> elements, ClassMeta<?> cm, String name, String parentPath, boolean isRoot, Object sampleBean, Map<Class<?>,Integer> typesInProgress) {
308      var et = cm.getElementType();
309      // Resolve element type from sample when generics are erased (et is Object) for proper list-of-bean
310      // expansion into leaf columns (e.g. members.list.element.name, members.list.element.age)
311      var sampleCollection = extractSampleCollection(sampleBean);
312      if (ine(sampleCollection)) {
313         var first = sampleCollection.iterator().next();
314         if (first != null)
315            et = marshallingContext.getClassMeta(first.getClass());
316      }
317      var listPath = parentPath != null ? parentPath + "." + name : name;
318      elements.add(new ParquetSchemaElement(name, null, null, isRoot ? null : OPTIONAL, 1, CONVERTED_LIST, null, null, null, null));
319      elements.add(new ParquetSchemaElement("list", null, null, REPEATED, 1, null, null, null, null, null));
320      Object elementSample = null;
321      if (ine(sampleCollection))
322         elementSample = sampleCollection.iterator().next();
323      addSchemaElements(elements, et, "element", listPath + ".list", false, elementSample, typesInProgress);
324   }
325
326   private static Collection<?> extractSampleCollection(Object sampleBean) {
327      if (sampleBean instanceof Collection<?> sampleBean2)
328         return sampleBean2;
329      return Collections.emptyList();
330   }
331
332   private void addMapSchema(List<ParquetSchemaElement> elements, ClassMeta<?> cm, String name, String parentPath, boolean isRoot, Map<Class<?>,Integer> typesInProgress) {
333      var vt = cm.getValueType();
334      // Parquet MAP stores keys as STRING; non-String keys (e.g. Enum) are serialized via toString/name()
335      // vt is null for BeanMap-category classes (cat has both MAP and BEANMAP set, which suppresses the
336      // key/value type resolution in ClassMeta.findKeyValueTypes()) -- genuinely reachable, not dead code.
337      if (vt == null)
338         vt = marshallingContext.getClassMeta(Object.class);
339      var mapPath = parentPath != null ? parentPath + "." + name : name;
340      elements.add(new ParquetSchemaElement(name, null, null, isRoot ? null : OPTIONAL, 1, CONVERTED_MAP, null, null, null, null));
341      elements.add(new ParquetSchemaElement("key_value", null, null, REPEATED, 2, null, null, null, null, null));
342      elements.add(new ParquetSchemaElement("key", TYPE_BYTE_ARRAY, null, REQUIRED, null, CONVERTED_UTF8, LOGICAL_TYPE_STRING, null, null, mapPath + ".key_value.key"));
343      addSchemaElements(elements, vt, "value", mapPath + ".key_value", false, null, typesInProgress);
344   }
345
346   private void addLeafSchema(List<ParquetSchemaElement> elements, ClassMeta<?> cm, String name, String parentPath, boolean isRoot) {
347      int repetition = isRoot ? REQUIRED : OPTIONAL;
348      String path = parentPath != null ? parentPath + "." + name : name;
349
350      if (cm.isBoolean()) {
351         elements.add(new ParquetSchemaElement(name, TYPE_BOOLEAN, null, repetition, null, null, null, null, null, path));
352      } else if (cm.is(byte.class) || cm.is(Byte.class) || cm.is(short.class) || cm.is(Short.class) || cm.is(int.class) || cm.is(Integer.class)) {
353         Integer ct;
354         if (cm.is(byte.class) || cm.is(Byte.class))
355            ct = CONVERTED_INT_8;
356         else if (cm.is(short.class) || cm.is(Short.class))
357            ct = CONVERTED_INT_16;
358         else  // int/Integer — the only remaining type the enclosing guard admits.
359            ct = CONVERTED_INT_32;
360         elements.add(new ParquetSchemaElement(name, TYPE_INT32, null, repetition, null, ct, null, null, null, path));
361      } else if (cm.is(Number.class)) {
362         // A statically-typed Number leaf has no fixed physical width.  Mapping it to INT32 silently
363         // truncates values above 2^31 (e.g. 5_000_000_000L -> 705032704) — GAP-12.  Default to INT64, which
364         // is lossless for every integral Number and keeps the wire value integral.  Integral matters for
365         // round-trip correctness: an ObjectSwap whose swap type is exactly Number (e.g.
366         // enum-as-ordinal) round-trips through String.valueOf(...), and "0" parses where "0.0" would not.
367         // When a concrete runtime sample is available (addBeanSchema), the column is re-typed to the actual
368         // subtype (INT32/INT64/DOUBLE) so fractional values are preserved too.  BigInteger/BigDecimal have
369         // their own lossless string mappings and never reach this branch.
370         elements.add(new ParquetSchemaElement(name, TYPE_INT64, null, repetition, null, CONVERTED_INT_64, null, null, null, path));
371      } else if (cm.is(long.class) || cm.is(Long.class)) {
372         elements.add(new ParquetSchemaElement(name, TYPE_INT64, null, repetition, null, CONVERTED_INT_64, null, null, null, path));
373      } else if (cm.is(float.class) || cm.is(Float.class)) {
374         elements.add(new ParquetSchemaElement(name, TYPE_FLOAT, null, repetition, null, null, null, null, null, path));
375      } else if (cm.is(double.class) || cm.is(Double.class)) {
376         elements.add(new ParquetSchemaElement(name, TYPE_DOUBLE, null, repetition, null, null, null, null, null, path));
377      } else if (cm.isAssignableTo(CharSequence.class)) {  // String is a CharSequence, so this covers both.
378         elements.add(new ParquetSchemaElement(name, TYPE_BYTE_ARRAY, null, repetition, null, CONVERTED_UTF8, LOGICAL_TYPE_STRING, null, null, path));
379      } else if (cm.isEnum()) {
380         elements.add(new ParquetSchemaElement(name, TYPE_BYTE_ARRAY, null, repetition, null, CONVERTED_ENUM, null, null, null, path));
381      } else if (cm.isByteArray()) {
382         elements.add(new ParquetSchemaElement(name, TYPE_BYTE_ARRAY, null, repetition, null, null, null, null, null, path));
383      } else if (nativeLogicalTypes && cm.inner() == LocalDate.class) {
384         // DATE (GAP-10): days since epoch as INT32.  Match on inner() (the concrete class resolved from a
385         // runtime sample) since a swapped property ClassMeta reports neither is(LocalDate) nor isDateOrCalendarOrTemporal.
386         elements.add(new ParquetSchemaElement(name, TYPE_INT32, null, repetition, null, CONVERTED_DATE, LOGICAL_TYPE_DATE, null, null, path));
387      } else if (nativeLogicalTypes && (cm.inner() == LocalTime.class || cm.inner() == OffsetTime.class)) {
388         // TIME (GAP-10): micros since midnight as INT64 (TIME_MICROS).
389         elements.add(new ParquetSchemaElement(name, TYPE_INT64, null, repetition, null, CONVERTED_TIME_MICROS, LOGICAL_TYPE_TIME, null, null, path));
390      } else if (nativeLogicalTypes && cm.isDateOrCalendarOrTemporal()) {
391         // TIMESTAMP (GAP-10): micros since epoch as INT64 (TIMESTAMP_MICROS) — sub-millisecond precise.
392         elements.add(new ParquetSchemaElement(name, TYPE_INT64, null, repetition, null, CONVERTED_TIMESTAMP_MICROS, LOGICAL_TYPE_TIMESTAMP, null, null, path));
393      } else if (cm.isDateOrCalendarOrTemporal() && writeDatesAsTimestamp) {
394         elements.add(new ParquetSchemaElement(name, TYPE_INT64, null, repetition, null, CONVERTED_TIMESTAMP_MILLIS, LOGICAL_TYPE_TIMESTAMP, null, null, path));
395      } else if (cm.isDateOrCalendarOrTemporal()) {
396         elements.add(new ParquetSchemaElement(name, TYPE_BYTE_ARRAY, null, repetition, null, CONVERTED_UTF8, LOGICAL_TYPE_STRING, null, null, path));
397      } else if (cm.isDuration()) {
398         elements.add(new ParquetSchemaElement(name, TYPE_BYTE_ARRAY, null, repetition, null, CONVERTED_UTF8, LOGICAL_TYPE_STRING, null, null, path));
399      } else if (nativeLogicalTypes && (cm.is(java.math.BigDecimal.class) || cm.is(java.math.BigInteger.class))) {
400         // DECIMAL (GAP-9): unscaled value as INT64 with scale/precision in the schema.  Scale is fixed at
401         // DECIMAL_SCALE; values requiring more fractional digits are rounded (HALF_UP) on write.
402         elements.add(new ParquetSchemaElement(name, TYPE_INT64, null, repetition, null, CONVERTED_DECIMAL, LOGICAL_TYPE_DECIMAL, DECIMAL_SCALE, DECIMAL_PRECISION, path));
403      } else if (cm.isAssignableTo(UUID.class)) {
404         elements.add(new ParquetSchemaElement(name, TYPE_FIXED_LEN_BYTE_ARRAY, 16, repetition, null, null, LOGICAL_TYPE_UUID, null, null, path));
405      } else {
406         elements.add(new ParquetSchemaElement(name, TYPE_BYTE_ARRAY, null, repetition, null, CONVERTED_UTF8, LOGICAL_TYPE_STRING, null, null, path));
407      }
408   }
409
410   /**
411    * Returns leaf (primitive) schema elements suitable for column chunk writing.
412    *
413    * @param schema The full schema from buildSchema.
414    * @return List of leaf elements with non-null paths.
415    */
416   public static List<ParquetSchemaElement> getLeafColumns(List<ParquetSchemaElement> schema) {
417      return schema.stream().filter(ParquetSchemaElement::isLeaf).collect(Collectors.toCollection(ArrayList::new));
418   }
419}