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.toml;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.io.*;
022import java.lang.reflect.*;
023import java.time.*;
024import java.time.temporal.*;
025import java.util.*;
026import java.util.function.*;
027
028import org.apache.juneau.commons.bean.*;
029import org.apache.juneau.marshall.*;
030import org.apache.juneau.marshall.serializer.*;
031import org.apache.juneau.marshall.stream.*;
032
033/**
034 * Session for serializing objects to TOML format.
035 */
036@SuppressWarnings({
037   "java:S110", // Inheritance depth acceptable for serializer session hierarchy
038   "java:S1172", // pMeta accepted for API consistency with callers that track property context
039   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
040   "java:S3776", // Cognitive complexity acceptable for serialization logic
041   "java:S6541", // Acceptable for session implementation
042   "rawtypes", // Raw types necessary for generic type handling
043   "resource", // TomlWriter lifecycle managed by SerializerPipe
044   "unused" // pMeta parameters of writeValue and isSimpleOrInlineTable are unused but kept so callers pass property context uniformly
045})
046public class TomlSerializerSession extends WriterSerializerSession implements RecordWritable {
047
048   /**
049    * Builder for TOML serializer session.
050    */
051   public static class Builder extends WriterSerializerSession.Builder<Builder> {
052
053      private final TomlSerializer ctx;
054
055      protected Builder(TomlSerializer ctx) {
056         super(reqnn("ctx", ctx));
057         this.ctx = ctx;
058      }
059
060      @Override
061      public TomlSerializerSession build() {
062         return new TomlSerializerSession(this);
063      }
064   }
065
066   /**
067    * Creates a session builder.
068    *
069    * @param ctx The serializer context.
070    *    <br>Cannot be <jk>null</jk>.
071    * @return The builder.
072    */
073   public static Builder create(TomlSerializer ctx) {
074      return new Builder(reqnn("ctx", ctx));
075   }
076
077   private final TomlSerializer ctx;
078
079   protected TomlSerializerSession(Builder builder) {
080      super(builder);
081      ctx = builder.ctx;
082   }
083
084   @Override /* RecordWritable */
085   public RecordWriter writeRecords(Object output) throws IOException {
086      return RecordAdapter.writer(this, output);
087   }
088
089   @Override /* RecordWritable */
090   public boolean isRecordStreaming() {
091      return false;
092   }
093
094   @Override
095   protected void doWrite(SerializerPipe out, Object o) throws IOException, SerializeException {
096      if (o == null)
097         return;
098      TomlWriter w = getTomlWriter(out);
099      writeRoot(w, o);
100   }
101
102   protected final TomlWriter getTomlWriter(SerializerPipe out) {
103      var output = out.getRawOutput();
104      if (output instanceof TomlWriter output2)
105         return output2;
106      var w = new TomlWriter(out.getWriter(), isUseWhitespace(), getMaxIndent(), isTrimStrings(), getUriResolver());
107      out.setWriter(w);
108      return w;
109   }
110
111   private void writeRoot(TomlWriter w, Object o) throws SerializeException {
112      ClassMeta<?> eType = getExpectedRootType(o);
113      if (eType.isBean()) {
114         writeBean(w, toBeanMap(o), "");
115      } else if (eType.isMap()) {
116         writeMapAtRoot(w, (Map<?,?>)o, eType);
117      } else if (eType.isCollection() || eType.isArray()) {
118         Collection<?> c = eType.isArray() ? toList(eType.inner(), o) : (Collection<?>)o;
119         ClassMeta<?> elType = eType.getElementType();
120         if (elType.isBean() || elType.isMap()) {
121            // Array of tables at root - use "item" as table name
122            for (Object item : c) {
123               w.blankLine();
124               w.arrayOfTablesHeader("item");
125               if (elType.isBean())
126                  writeBean(w, toBeanMap(item), "item");
127               else
128                  writeMapAsTable(w, "item", (Map)item, elType);
129            }
130         } else {
131            // Root array of simples: wrap in _value
132            w.bareKey("_value");
133            w.w(" = ");
134            writeArray(w, c, eType);
135            w.w('\n');
136         }
137      } else {
138         // Root primitive: wrap in _value
139         w.bareKey("_value");
140         w.w(" = ");
141         writeValue(w, o, eType);
142         w.w('\n');
143      }
144   }
145
146   private void writeMapAtRoot(TomlWriter w, Map<?,?> map, ClassMeta<?> type) throws SerializeException {
147      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
148      forEachEntry(map, e -> {
149         String k = e.getKey() == null ? "null" : toString(e.getKey());
150         Object v = e.getValue();
151         if (!checkNull.test(v))
152            return;
153         writeMapEntry(w, k, k, v, type);
154      });
155   }
156
157   private void writeBean(TomlWriter w, BeanMap<?> m, String tablePath) throws SerializeException {
158      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
159      List<Map.Entry<BeanPropertyMeta,Object>> simple = l();
160      List<Map.Entry<BeanPropertyMeta,Object>> complex = l();
161
162      m.forEachValue(checkNull, (pMeta, key, value, thrown) -> {
163         if (nn(thrown))
164            onBeanGetterException(pMeta, thrown);
165         ClassMeta<?> cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
166         if (canIgnoreValue(pMeta, key, value))
167            return;
168         ClassMeta<?> aType = value == null ? cMeta : getClassMetaForObject(value, cMeta);
169         if (isSimpleOrInlineTable(aType, value, pMeta)) {
170            simple.add(new AbstractMap.SimpleEntry<>(pMeta, value));
171         } else {
172            complex.add(new AbstractMap.SimpleEntry<>(pMeta, value));
173         }
174      });
175
176      if (ctx.sortKeys) {
177         simple.sort(Comparator.comparing(e -> e.getKey().getName()));
178         complex.sort(Comparator.comparing(e -> e.getKey().getName()));
179      }
180
181      // Pass 1: simple values and inline tables
182      for (var e : simple) {
183         String key = e.getKey().getName();
184         Object value = e.getValue();
185         BeanPropertyMeta pMeta = e.getKey();
186         writeKeyValue(w, key, value, pMeta);
187      }
188
189      // Pass 2: tables and array of tables
190      for (var e : complex) {
191         String key = e.getKey().getName();
192         Object value = e.getValue();
193         BeanPropertyMeta pMeta = e.getKey();
194         ClassMeta<?> cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
195         String newPath = tablePath.isEmpty() ? key : tablePath + "." + key;
196
197         if (nn(value)) {
198            w.blankLine();
199            ClassMeta<?> aType = getClassMetaForObject(value, cMeta);
200            if (aType.isBean()) {
201               w.tableHeader(newPath);
202               writeBean(w, toBeanMap(value), newPath);
203            } else if (aType.isMap()) {
204               w.tableHeader(newPath);
205               writeMapAsTable(w, newPath, (Map)value, cMeta);
206            } else {
207               // Only table-worthy collections/arrays (non-empty, all bean/map elements) reach this bucket.
208               for (Object item : tableElements(value)) {
209                  w.blankLine();
210                  w.arrayOfTablesHeader(newPath);
211                  if (item instanceof Map<?,?> im)
212                     writeMapAsTable(w, newPath, im, getClassMetaForObject(item));
213                  else
214                     writeBean(w, toBeanMap(item), newPath);
215               }
216            }
217         } else if (isKeepNullProperties()) {
218            // The "complex" bucket is classified purely from the property's declared type (cMeta),
219            // before the value itself is known, so a null-valued Map/Collection-of-bean property lands
220            // here just like a populated one -- but this branch previously only handled the non-null
221            // case, silently dropping the property (regardless of keepNullProperties()) since there's
222            // no sub-structure to write a table header for. Fall back to a plain key = <NULL> line,
223            // same as writeKeyValue already does for a null-valued simple property.
224            writeKeyValue(w, key, null, pMeta);
225         }
226      }
227   }
228
229   private void writeKeyValue(TomlWriter w, String key, Object value, BeanPropertyMeta pMeta) throws SerializeException {
230      ClassMeta<?> cMeta;
231      if (pMeta != null)
232         cMeta = (ClassMeta<?>) pMeta.getBeanInfo();
233      else if (value != null)
234         cMeta = getClassMetaForObject(value);
235      else
236         cMeta = object();
237      ClassMeta<?> aType = value == null ? cMeta : getClassMetaForObject(value, cMeta);
238
239      // Apply swap
240      var swap = aType.getSwap(this);
241      if (nn(swap)) {
242         value = swap(swap, value);
243         aType = swap.getSwapClassMeta(this);
244         if (aType.isObject())
245            aType = getClassMetaForObject(value);
246      }
247
248      if (value == null && !isKeepNullProperties())
249         return;
250
251      writeKey(w, key);
252      w.w(" = ");
253      if (value == null)
254         w.stringValue(ctx.getNullValue());
255      else
256         writeValue(w, value, aType, pMeta);
257      w.w('\n');
258   }
259
260   private static void writeKey(TomlWriter w, String key) {
261      if (w.isBareKey(key))
262         w.bareKey(key);
263      else
264         w.quotedKey(key);
265   }
266
267   private void writeValue(TomlWriter w, Object value, ClassMeta<?> aType, BeanPropertyMeta pMeta) throws SerializeException {
268      if (value == null) {
269         w.stringValue(ctx.getNullValue());
270         return;
271      }
272      // Resolve to the runtime type and apply any registered swap.  Required for collection/array
273      // elements that arrive here without having gone through writeKeyValue's swap step (e.g.
274      // List<Locale> elements where the bean-property swap is on the List, not on Locale).
275      // Without this, Locale list-elements wire as Locale.toString() ("en_US") regardless of
276      // the configured LocaleFormat, breaking BCP_47 round-trips on the parser side.
277      var rType = getClassMetaForObject(value, aType);
278      var swap = rType.getSwap(this);
279      if (nn(swap)) {
280         value = swap(swap, value);
281         rType = swap.getSwapClassMeta(this);
282         if (rType.isObject())
283            rType = getClassMetaForObject(value);
284      }
285      // Always dispatch on the runtime type: the declared/hinted type is often erased (e.g. Object for
286      // the elements of a List<Integer> whose runtime class is a raw List).
287      aType = rType;
288      if (value == null) {
289         w.stringValue(ctx.getNullValue());
290         return;
291      }
292      // A bean or map in value position (after '=' or inside an array) can only be an inline table.
293      if (aType.isBean()) {
294         writeInlineTable(w, toBeanMap(value));
295      } else if (aType.isMap()) {
296         writeInlineTable(w, (Map<?,?>)value);
297      } else if (aType.isNumber()) {
298         if (value instanceof Float || value instanceof Double)
299            w.floatValue(((Number)value).doubleValue());
300         else
301            w.integerValue(((Number)value).longValue());
302      } else if (aType.isBoolean()) {
303         w.booleanValue((Boolean)value);
304      } else if (aType.isEnum()) {
305         w.stringValue(((Enum<?>)value).name());
306      } else if (aType.isDate()) {
307         w.w(writeDate((Date)value, aType));
308      } else if (aType.isCalendar()) {
309         w.w(writeCalendar(value, aType));
310      } else if (aType.isTemporal()) {
311         Class<?> inner = aType.inner();
312         if (inner == Year.class || inner == YearMonth.class)
313            w.stringValue(writeTemporal((TemporalAccessor)value, aType));
314         else
315            w.w(writeTemporal((TemporalAccessor)value, aType));
316      } else if (aType.isDuration()) {
317         w.stringValue(writeDuration((Duration)value));
318      } else if (aType.isPeriod()) {
319         w.stringValue(writePeriod((Period)value));
320      } else if (value.getClass().isArray()) {
321         // Generalized fallback: when aType arrives erased — most commonly
322         // because the value is a typed List<T[]> bean-property element and the parent List's
323         // elementType resolved to Object (getClassMetaForObject(value, cMeta) ignores the
324         // cMeta hint when value != null and returns the raw runtime ArrayList ClassMeta) —
325         // the isCollection/isArray dispatch below doesn't fire and the value would otherwise
326         // stringify via Object.toString() ("[B@<hash>", "[I@<hash>", …).  Detect the array
327         // shape from the runtime class and emit a TOML inline-array, recursing on each
328         // (boxed) element through writeValue so the per-element wire form (integer / float
329         // / bool / string) follows the standard dispatch.  Subsumes the byte[]-only fix that
330         // originally landed for the BinaryFormat.NOT_SET round-trip and extends it to
331         // int[]/long[]/short[]/float[]/double[]/boolean[]/char[]/String[]/Object[] (and any
332         // nested array shape via the recursive writeValue step).  At configured (non-NOT_SET)
333         // binary formats the per-property BinarySwap has already fired in writeKeyValue's
334         // swap step, so byte[] values reach this branch only at NOT_SET — a no-op for the
335         // configured-format paths.  Array.get auto-boxes primitive
336         // elements, so a single runtime-typed loop covers every primitive component type.
337         int len = Array.getLength(value);
338         w.arrayStart();
339         for (int i = 0; i < len; i++) {
340            if (i > 0) w.w(", ");
341            Object el = Array.get(value, i);
342            if (el == null)
343               w.stringValue(ctx.getNullValue());
344            else
345               writeValue(w, el, getClassMetaForObject(el), null);
346         }
347         w.arrayEnd();
348      } else if (aType.isCollection() || aType.isArray()) {
349         Collection<?> c = aType.isArray() ? toList(aType.inner(), value) : (Collection<?>)value;
350         ClassMeta<?> elType = aType.getElementType();
351         // Beans/maps become inline tables inside the array; nested collections become nested arrays.
352         w.arrayStart();
353         boolean first = true;
354         for (Object el : c) {
355            if (!first) w.w(", ");
356            first = false;
357            if (el == null)
358               w.stringValue(ctx.getNullValue());
359            else
360               writeValue(w, el, elType, null);
361         }
362         w.arrayEnd();
363      } else {
364         w.stringValue(toString(value));
365      }
366   }
367
368   private void writeValue(TomlWriter w, Object value, ClassMeta<?> aType) throws SerializeException {
369      writeValue(w, value, aType, null);
370   }
371
372   private void writeInlineTable(TomlWriter w, BeanMap<?> bm) throws SerializeException {
373      w.inlineTableStart();
374      boolean[] first = {true};
375      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
376      bm.forEachValue(checkNull, (pm, k, v, th) -> {
377         if (!first[0]) w.w(", ");
378         first[0] = false;
379         writeKey(w, k);
380         w.w(" = ");
381         writeValue(w, v, (ClassMeta<?>) pm.getBeanInfo(), pm);
382      });
383      w.inlineTableEnd();
384   }
385
386   private void writeInlineTable(TomlWriter w, Map<?,?> map) throws SerializeException {
387      w.inlineTableStart();
388      boolean[] first = {true};
389      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
390      forEachEntry(map, e -> {
391         Object v = e.getValue();
392         if (!checkNull.test(v))
393            return;
394         if (!first[0]) w.w(", ");
395         first[0] = false;
396         writeKey(w, e.getKey() == null ? "null" : toString(e.getKey()));
397         w.w(" = ");
398         writeValue(w, v, object(), null);
399      });
400      w.inlineTableEnd();
401   }
402
403   /**
404    * Returns the elements of a collection/array value if it is "table-worthy" (non-empty with every element a
405    * non-null bean or map, so it can be written as a TOML array of tables), otherwise an empty list.
406    */
407   private List<Object> tableElements(Object value) throws SerializeException {
408      List<Object> l;
409      if (value instanceof Collection<?> c)
410         l = new ArrayList<>(c);
411      else if (value != null && value.getClass().isArray() && !value.getClass().getComponentType().isPrimitive())
412         l = l((Object[])value);
413      else
414         return Collections.emptyList();
415      if (l.isEmpty())
416         return l;
417      for (Object el : l) {
418         if (el instanceof Map)
419            continue;
420         if (el == null || !getClassMetaForObject(el).isBean())
421            return Collections.emptyList();
422      }
423      return l;
424   }
425
426   private void writeMapAsTable(TomlWriter w, String path, Map<?,?> map, ClassMeta<?> type) throws SerializeException {
427      Predicate<Object> checkNull = x -> isKeepNullProperties() || nn(x);
428      forEachEntry(map, e -> {
429         String k = e.getKey() == null ? "null" : toString(e.getKey());
430         Object v = e.getValue();
431         if (!checkNull.test(v))
432            return;
433         writeMapEntry(w, path.isEmpty() ? k : path + "." + k, k, v, type);
434      });
435   }
436
437   /**
438    * Writes a single raw-map entry, recursing into a nested {@code [table]}/{@code [[array-of-tables]]}
439    * when the value is itself a {@link Map} or a {@link Collection} of Map/bean elements -- mirroring
440    * {@link #writeBean}'s complex pass, but for raw Map values, where there's no declared bean-property
441    * generic type to consult, so table-worthiness is determined from the actual runtime elements
442    * instead (unlike a bean property, a raw Map value's Collection entries never carry element-type
443    * info to fall back on).
444    */
445   private void writeMapEntry(TomlWriter w, String tablePath, String key, Object value, ClassMeta<?> hint) throws SerializeException {
446      if (value instanceof Map<?,?> v2) {
447         w.blankLine();
448         w.tableHeader(tablePath);
449         writeMapAsTable(w, tablePath, v2, getClassMetaForObject(value, hint));
450      } else if (! tableElements(value).isEmpty()) {
451         for (Object item : tableElements(value)) {
452            w.blankLine();
453            w.arrayOfTablesHeader(tablePath);
454            if (item instanceof Map<?,?> im)
455               writeMapAsTable(w, tablePath, im, getClassMetaForObject(item));
456            else
457               writeBean(w, toBeanMap(item), tablePath);
458         }
459      } else {
460         writeKeyValue(w, key, value, null);
461      }
462   }
463
464   private void writeArray(TomlWriter w, Collection<?> c, ClassMeta<?> type) throws SerializeException {
465      ClassMeta<?> elType = type.getElementType();
466      w.arrayStart();
467      boolean first = true;
468      for (Object el : c) {
469         if (!first) w.w(", ");
470         first = false;
471         writeValue(w, el, elType);
472      }
473      w.arrayEnd();
474   }
475
476   private boolean isSimpleOrInlineTable(ClassMeta<?> aType, Object value, BeanPropertyMeta pMeta) throws SerializeException {
477      if (aType.isBean()) {
478         BeanMap<?> bm = toBeanMap(value);
479         return shouldUseInlineTable(bm);
480      }
481      if (aType.isCollectionOrArray())
482         return tableElements(value).isEmpty();
483      return !aType.isMap();
484   }
485
486   private boolean shouldUseInlineTable(BeanMap<?> bm) {
487      if (!ctx.useInlineTables)
488         return false;
489      int count = 0;
490      for (BeanPropertyMeta p : bm.getMeta().getProperties().values()) {
491         if (!p.isWriteOnly()) {
492            ClassMeta<?> cm = (ClassMeta<?>) p.getBeanInfo();
493            if (cm.isBean() || cm.isMap() || (cm.isCollectionOrArray() && cm.getElementType().isBean()))
494               return false;
495            count++;
496            if (count > ctx.inlineTableThreshold)
497               return false;
498         }
499      }
500      return count <= ctx.inlineTableThreshold;
501   }
502}