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.xml;
018
019import static org.apache.juneau.commons.utils.CollectionUtils.*;
020import static org.apache.juneau.commons.utils.IoUtils.*;
021import static org.apache.juneau.commons.utils.ObjectUtils.*;
022import static org.apache.juneau.commons.utils.Shorts.*;
023import static org.apache.juneau.marshall.xml.XmlFormat.*;
024import static org.apache.juneau.marshall.xml.XmlSerializerSession.ContentResult.*;
025import static org.apache.juneau.marshall.xml.XmlSerializerSession.JsonType.*;
026
027import java.io.*;
028import java.lang.reflect.*;
029import java.time.*;
030import java.time.temporal.*;
031import java.util.*;
032import java.util.AbstractMap.*;
033import java.util.function.*;
034
035import org.apache.juneau.commons.bean.*;
036import org.apache.juneau.commons.lang.*;
037import org.apache.juneau.marshall.*;
038import org.apache.juneau.marshall.serializer.*;
039import org.apache.juneau.marshall.stream.*;
040
041/**
042 * Session object that lives for the duration of a single use of {@link XmlSerializer}.
043 *
044 * <h5 class='section'>Notes:</h5><ul>
045 *    <li class='warn'>This class is not thread safe and is typically discarded after one use.
046 * </ul>
047 *
048 * <h5 class='section'>See Also:</h5><ul>
049 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/XmlSupport">XML</a>
050
051 * </ul>
052 */
053@SuppressWarnings({
054   "java:S110", // Deep inheritance inherent to the serializer/parser session hierarchy
055   "java:S1192", // Duplicated literals (argument/property names) read more clearly inline than as constants
056   "java:S1452", // create() and getXmlWriter() return wildcard Builder<?>/XmlWriter<?> because the concrete self-type is not known statically
057   "java:S3776", // Cognitive complexity acceptable for this specific logic
058   "java:S6541", // Single-threaded session contexts do not require synchronization
059   "null", // Null handling verified by context or framework
060   "rawtypes", // Raw Map/Collection necessary for serializer dispatch over heterogeneous types
061   "resource", // Writer/Closeable resources managed by try-with-resources; analyzer FP in lambdas
062   "unchecked" // Type erasure requires unchecked casts in serializer dispatch
063})
064public class XmlSerializerSession extends WriterSerializerSession implements RecordWritable {
065
066   /**
067    * Builder class.
068    */
069   @SuppressWarnings({
070      "java:S119" // 'SELF' (CRTP self-type) is intentional and clearer than a single-letter name.
071   })
072   public abstract static class Builder<SELF extends Builder<SELF>> extends WriterSerializerSession.Builder<SELF> {
073
074      private boolean addNamespaceUrisToRoot;
075      private boolean autoDetectNamespaces;
076      private String defaultNamespace;
077      private boolean enableNamespaces;
078      private String textNodeDelimiter;
079      private final XmlSerializer ctx;
080
081      /**
082       * Constructor
083       *
084       * @param ctx The context creating this session.
085       *    <br>Cannot be <jk>null</jk>.
086       */
087      protected Builder(XmlSerializer ctx) {
088         super(reqnn("ctx", ctx));
089         this.ctx = ctx;
090         addNamespaceUrisToRoot = ctx.isAddNamespaceUrlsToRoot();
091         autoDetectNamespaces = ctx.isAutoDetectNamespaces();
092         defaultNamespace = ctx.getDefaultNamespace() == null ? null : ctx.getDefaultNamespace().toString();
093         enableNamespaces = ctx.isEnableNamespaces();
094         textNodeDelimiter = ctx.getTextNodeDelimiter();
095      }
096
097      @Override
098      public XmlSerializerSession build() {
099         return new XmlSerializerSession(this);
100      }
101
102      /**
103       * Add namespace URLs to the root element.
104       *
105       * @param value The new value for this setting.
106       * @return This object.
107       */
108      public SELF addNamespaceUrisToRoot(boolean value) {
109         addNamespaceUrisToRoot = value;
110         return self();
111      }
112
113      /**
114       * Auto-detect namespace usage.
115       *
116       * @param value The new value for this setting.
117       * @return This object.
118       */
119      public SELF autoDetectNamespaces(boolean value) {
120         autoDetectNamespaces = value;
121         return self();
122      }
123
124      /**
125       * Default namespace.
126       *
127       * @param value The new value for this setting.
128       * @return This object.
129       */
130      public SELF defaultNamespace(String value) {
131         defaultNamespace = value;
132         return self();
133      }
134
135      /**
136       * Enable support for XML namespaces.
137       *
138       * @param value The new value for this setting.
139       * @return This object.
140       */
141      public SELF enableNamespaces(boolean value) {
142         enableNamespaces = value;
143         return self();
144      }
145
146      /**
147       * Text node delimiter.
148       *
149       * @param value The new value for this setting.
150       * @return This object.
151       */
152      public SELF textNodeDelimiter(String value) {
153         textNodeDelimiter = value == null ? "" : value;
154         return self();
155      }
156
157      @Override /* Overridden from Builder */
158      public SELF property(String key, Object value) {
159         if (key == null) { super.property(key, value); return self(); }
160         switch (key) {
161            case "addNamespaceUrisToRoot", "XmlSerializerSession.addNamespaceUrisToRoot":
162               return addNamespaceUrisToRoot(cvt(value, Boolean.class));
163            case "autoDetectNamespaces", "XmlSerializerSession.autoDetectNamespaces":
164               return autoDetectNamespaces(cvt(value, Boolean.class));
165            case "defaultNamespace", "XmlSerializerSession.defaultNamespace":
166               return defaultNamespace(cvt(value, String.class));
167            case "enableNamespaces", "XmlSerializerSession.enableNamespaces":
168               return enableNamespaces(cvt(value, Boolean.class));
169            case "textNodeDelimiter", "XmlSerializerSession.textNodeDelimiter":
170               return textNodeDelimiter(cvt(value, String.class));
171            default:
172               super.property(key, value);
173               return self();
174         }
175      }
176
177   }
178
179   /**
180    * Concrete default builder leaf for the non-subclassed {@code create()} path (CRTP terminal).
181    */
182   public static final class DefaultBuilder extends Builder<DefaultBuilder> {
183
184      DefaultBuilder(XmlSerializer ctx) {
185         super(ctx);
186      }
187   }
188
189   /**
190    * Identifies what the contents were of a serialized bean.
191    */
192   public enum ContentResult {
193      CR_VOID,      // No content...append "/>" to the start tag.
194      CR_EMPTY,     // No content...append "/>" to the start tag if XML, "/></end>" if HTML.
195      CR_MIXED,     // Mixed content...don't add whitespace.
196      CR_ELEMENTS   // Elements...use normal whitespace rules.
197   }
198
199   enum JsonType {
200      STRING("string"), BOOLEAN("boolean"), NUMBER("number"), ARRAY("array"), OBJECT("object"), NULL("null");
201
202      private final String value;
203
204      JsonType(String value) {
205         this.value = value;
206      }
207
208      @Override
209      public String toString() {
210         return value;
211      }
212
213      @SuppressWarnings({
214         "java:S1871" // return true is the only correct action here; coincidental match with isXmlText is not a logic error
215      })
216      boolean isOneOf(JsonType...types) {
217         for (var type : types)
218            if (type == this)
219               return true;
220         return false;
221      }
222   }
223
224   /**
225    * Creates a new builder for this object.
226    *
227    * @param ctx The context creating this session.
228    *    <br>Cannot be <jk>null</jk>.
229    * @return A new builder.
230    */
231   public static Builder<?> create(XmlSerializer ctx) {
232      return new DefaultBuilder(reqnn("ctx", ctx));
233   }
234
235   private final String textNodeDelimiter;
236   private final boolean addNamespaceUrisToRoot;
237   private final boolean autoDetectNamespaces;
238   private final boolean enableNamespaces;
239   private final XmlSerializer ctx;
240   private Namespace defaultNamespace;
241   private List<Namespace> namespaces = l();
242
243   /**
244    * Constructor.
245    *
246    * @param builder The builder for this object.
247    */
248   protected XmlSerializerSession(Builder<?> builder) {
249      super(builder);
250      ctx = builder.ctx;
251      addNamespaceUrisToRoot = builder.addNamespaceUrisToRoot;
252      autoDetectNamespaces = builder.autoDetectNamespaces;
253      enableNamespaces = builder.enableNamespaces;
254      textNodeDelimiter = builder.textNodeDelimiter;
255      // namespaces must be populated BEFORE findDefaultNamespace() runs below -- it resolves a name-only
256      // or URI-only default namespace by searching getNamespaces(), which otherwise still sees the
257      // field initializer's empty list here, so both search loops always iterate zero times and the
258      // namespace is returned unresolved (missing whichever of name/uri wasn't supplied).
259      namespaces = new ArrayList<>(ctx.getNamespaces());
260      defaultNamespace = findDefaultNamespace(
261         builder.defaultNamespace != null ? Namespace.create(builder.defaultNamespace) : ctx.getDefaultNamespace()
262      );
263   }
264
265   /**
266    * Returns the language-specific metadata on the specified bean.
267    *
268    * @param bm The bean to return the metadata on.
269    * @return The metadata.
270    */
271   public XmlBeanMeta getXmlBeanMeta(BeanMeta<?> bm) {
272      return ctx.getXmlBeanMeta(bm);
273   }
274
275   /**
276    * Returns the language-specific metadata on the specified bean property.
277    *
278    * @param bpm The bean property to return the metadata on.
279    * @return The metadata.
280    */
281   public XmlBeanPropertyMeta getXmlBeanPropertyMeta(BeanPropertyMeta bpm) {
282      return bpm == null ? XmlBeanPropertyMeta.DEFAULT : ctx.getXmlBeanPropertyMeta(bpm);
283   }
284
285   /**
286    * Returns the language-specific metadata on the specified class.
287    *
288    * @param cm The class to return the metadata on.
289    * @return The metadata.
290    */
291   public XmlClassMeta getXmlClassMeta(ClassMeta<?> cm) {
292      return ctx.getXmlClassMeta(cm);
293   }
294
295   /**
296    * Converts the specified output target object to an {@link XmlWriter}.
297    *
298    * @param out The output target object.
299    *    <br>Must not be <jk>null</jk>.
300    * @return The output target object wrapped in an {@link XmlWriter}.
301    */
302   public final XmlWriter<?> getXmlWriter(SerializerPipe out) {
303      var output = out.getRawOutput();
304      if (output instanceof XmlWriter<?> output2)
305         return output2;
306      var w = new BasicXmlWriter(out.getWriter(), isUseWhitespace(), getMaxIndent(), isTrimStrings(), getQuoteChar(), getUriResolver(), isEnableNamespaces(), defaultNamespace);
307      out.setWriter(w);
308      return w;
309   }
310
311   /**
312    * Add a namespace to this session.
313    *
314    * @param ns The namespace being added.
315    */
316   private void addNamespace(Namespace ns) {
317      if (ns == defaultNamespace)
318         return;
319
320      for (var n : namespaces)
321         if (n == ns)
322            return;
323
324      if (nn(defaultNamespace) && (ns.uri.equals(defaultNamespace.uri) || ns.name.equals(defaultNamespace.name)))
325         defaultNamespace = ns;
326      else
327         namespaces = addAll(namespaces, ns);
328   }
329
330   private Namespace findDefaultNamespace(Namespace n) {
331      if (n == null)
332         return null;
333      if (nn(n.name) && nn(n.uri))
334         return n;
335      if (n.uri == null) {
336         for (var n2 : getNamespaces())
337            if (n2.name.equals(n.name))
338               return n2;
339      }
340      if (n.name == null) {
341         for (var n2 : getNamespaces())
342            if (n2.uri.equals(n.uri))
343               return n2;
344      }
345      return n;
346   }
347
348   /**
349    * Checks if an object is a text node (String or primitive type).
350    */
351   private static boolean isTextNode(Object o) {
352      if (o == null)
353         return false;
354      var c = o.getClass();
355      // Text nodes are strings and primitives (not beans, collections, arrays, or other complex types)
356      return CharSequence.class.isAssignableFrom(c) || Number.class.isAssignableFrom(c) || Boolean.class.isAssignableFrom(c) || c.isPrimitive();
357   }
358
359   private boolean isXmlText(XmlFormat format, ClassMeta<?> sType) {
360      if (format == XMLTEXT)
361         return true;
362      var xcm = getXmlClassMeta(sType);
363      if (xcm == null)
364         return false;
365      return xcm.getFormat() == XMLTEXT;
366   }
367
368   private boolean isRawText(XmlFormat format, ClassMeta<?> sType) {
369      if (format == RAWTEXT)
370         return true;
371      var xcm = getXmlClassMeta(sType);
372      if (xcm == null)
373         return false;
374      return xcm.getFormat() == RAWTEXT;
375   }
376
377   private Optional<Map.Entry<String,Object>> getPropertyKeyValueIfNotIgnored(BeanPropertyValue p, BeanPropertyMeta pMeta) {
378      var key = p.getName();
379      var value = p.getValue();
380      if (nn(p.getThrown()))
381         onBeanGetterException(pMeta, p.getThrown());
382      if (canIgnoreValue(pMeta, key, value))
383         return oe();
384      return o(new SimpleEntry<>(key, value));
385   }
386
387   private ContentResult writeBeanMap(XmlWriter<?> out, BeanMap<?> m, Namespace elementNs, boolean isCollapsed, boolean isMixedOrText) throws SerializeException {
388      boolean hasChildren = false;
389      var bm = m.getMeta();
390
391      var lp = new ArrayList<BeanPropertyValue>();
392
393      var checkNull = (Predicate<Object>)(x -> isKeepNullProperties() || nn(x));
394      m.forEachValue(checkNull, (pMeta, key, value, thrown) -> lp.add(new BeanPropertyValue(pMeta, key, value, thrown)));
395
396      var xbm = getXmlBeanMeta(bm);
397
398      // @formatter:off
399      Set<String> attrs = xbm.getAttrPropertyNames();
400      Set<String> elements = xbm.getElementPropertyNames();
401      Set<String> collapsedElements = xbm.getCollapsedPropertyNames();
402      var attrsProperty = xbm.getAttrsPropertyName();
403      var contentProperty = xbm.getContentPropertyName();
404      // @formatter:on
405
406      XmlFormat cf = null;
407
408      Object content = null;
409      ClassMeta<?> contentType = null;
410      for (var p : lp) {
411         var n = p.getName();
412         if (attrs.contains(n) || attrs.contains("*") || n.equals(attrsProperty)) {
413            var pMeta = p.getMeta();
414            if (pMeta.canRead()) {
415               var kv = getPropertyKeyValueIfNotIgnored(p, pMeta);
416               if (kv.isEmpty())
417                  continue;
418               var key = kv.get().getKey();
419               var value = kv.get().getValue();
420               var bpXml = getXmlBeanPropertyMeta(pMeta);
421               var ns = (isEnableNamespaces() && bpXml.getNamespace() != elementNs ? bpXml.getNamespace() : null);
422
423               if (pMeta.isUri()) {
424                  out.attrUri(ns, key, value);
425               } else if (n.equals(attrsProperty)) {
426                  if (value instanceof BeanMap<?> value2) {
427                     value2.forEachValue(x -> true, (pMeta2, key2, value3, thrown2) -> {
428                        if (nn(thrown2))
429                           onBeanGetterException(pMeta, thrown2);
430                        out.attr(ns, key2, value3);
431                     });
432                  } else /* Map */ {
433                     var m2 = (Map)value;
434                     if (nn(m2))
435                        m2.forEach((k, v) -> out.attr(ns, s(k), v));
436                  }
437               } else {
438                  out.attr(ns, key, value);
439               }
440            }
441         }
442      }
443
444      boolean hasContent = false;
445      boolean preserveWhitespace = false;
446      boolean isVoidElement = xbm.getContentFormat() == VOID;
447
448      for (var p : lp) {
449         BeanPropertyMeta pMeta = p.getMeta();
450         if (pMeta.canRead()) {
451            ClassMeta<?> cMeta = (ClassMeta<?>) p.getBeanInfo();
452
453            var n = p.getName();
454            if (n.equals(contentProperty)) {
455               content = p.getValue();
456               contentType = (ClassMeta<?>) p.getBeanInfo();
457               hasContent = true;
458               cf = xbm.getContentFormat();
459               if (cf.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT))
460                  isMixedOrText = true;
461               if (cf.isOneOf(MIXED_PWS, TEXT_PWS))
462                  preserveWhitespace = true;
463               if ((contentType.isCollection() && ((Collection)content).isEmpty()) || (contentType.isArray() && Array.getLength(content) == 0))
464                  hasContent = false;
465            } else if (elements.contains(n) || collapsedElements.contains(n) || elements.contains("*") || collapsedElements.contains("*")) {
466               var kv = getPropertyKeyValueIfNotIgnored(p, pMeta);
467               if (kv.isEmpty())
468                  continue;
469               var key = kv.get().getKey();
470               var value = kv.get().getValue();
471               if (! hasChildren) {
472                  hasChildren = true;
473                  out.appendIf(! isCollapsed, '>').nlIf(! isMixedOrText, indent);
474               }
475
476               var bpXml = getXmlBeanPropertyMeta(pMeta);
477               writeAnything(out, value, cMeta, key, null, bpXml.getNamespace(), false, bpXml.getXmlFormat(), isMixedOrText, false, pMeta);
478            }
479         }
480      }
481      if (contentProperty == null && ! hasContent) {
482         if (hasChildren) {
483            return CR_ELEMENTS;
484         } else if (isVoidElement) {
485            return CR_VOID;
486         } else {
487            return CR_EMPTY;
488         }
489      }
490
491      // Serialize XML content.
492      if (nn(content)) {
493         out.w('>').nlIf(! isMixedOrText, indent);
494         if (contentType == null) {
495            // No content type specified - skip content serialization
496         } else if (contentType.isCollection()) {
497            var c = (Collection)content;
498            boolean previousWasTextNode = false;
499            for (var value : c) {
500               boolean currentIsTextNode = isTextNode(value);
501               // Insert delimiter between consecutive text nodes
502               if (previousWasTextNode && currentIsTextNode && nn(textNodeDelimiter) && ! textNodeDelimiter.isEmpty()) {
503                  out.append(textNodeDelimiter);
504               }
505               writeAnything(out, value, contentType.getElementType(), null, null, null, false, cf, isMixedOrText, preserveWhitespace, null);
506               previousWasTextNode = currentIsTextNode;
507            }
508         } else if (contentType.isArray()) {
509            var c = toList(Object[].class, content);
510            boolean previousWasTextNode = false;
511            for (var value : c) {
512               boolean currentIsTextNode = isTextNode(value);
513               // Insert delimiter between consecutive text nodes
514               if (previousWasTextNode && currentIsTextNode && nn(textNodeDelimiter) && ! textNodeDelimiter.isEmpty()) {
515                  out.append(textNodeDelimiter);
516               }
517               writeAnything(out, value, contentType.getElementType(), null, null, null, false, cf, isMixedOrText, preserveWhitespace, null);
518               previousWasTextNode = currentIsTextNode;
519            }
520         } else {
521            writeAnything(out, content, contentType, null, null, null, false, cf, isMixedOrText, preserveWhitespace, null);
522         }
523      } else {
524         // A null FREEFORM content property represents an empty container element (e.g. an empty HTML <table> or
525         // <thead>), which must render as an empty element rather than an explicit null.  Emitting nil='true' here
526         // is surprising for such containers.  Other content formats (TEXT/TEXT_PWS/XMLTEXT for null-vs-empty-string
527         // round-tripping, and MIXED/MIXED_PWS) retain the nil marker to preserve existing behavior.
528         if (isAddJsonTags() && xbm.getContentFormat() != FREEFORM)
529            out.attr("nil", "true");
530         out.w('>').nlIf(! isMixedOrText, indent);
531      }
532      return isMixedOrText ? CR_MIXED : CR_ELEMENTS;
533   }
534
535   private XmlWriter<?> writeCollection(XmlWriter<?> out, Object in, ClassMeta<?> sType, ClassMeta<?> eType, BeanPropertyMeta ppMeta, boolean isMixed) throws SerializeException {
536
537      var eeType = eType.getElementType();
538
539      var c = (sType.isCollection() ? (Collection)in : toList(sType.inner(), in));
540
541      String type2 = null;
542
543      var eName = Holder.of(type2);
544      var eNs = Holder.<Namespace>empty();
545
546      if (nn(ppMeta)) {
547         XmlBeanPropertyMeta bpXml = getXmlBeanPropertyMeta(ppMeta);
548         eName.set(bpXml.getChildName());
549         eNs.set(bpXml.getNamespace());
550      }
551
552      // Track if previous element was a text node for delimiter insertion
553      var previousWasTextNode = Holder.of(false);
554
555      forEachEntry(c, x -> {
556         var currentIsTextNode = isTextNode(x);
557
558         // Insert delimiter between consecutive text nodes
559         if (isTrue(previousWasTextNode.get()) && currentIsTextNode && nn(textNodeDelimiter) && ! textNodeDelimiter.isEmpty()) {
560            out.append(textNodeDelimiter);
561         }
562
563         writeAnything(out, x, eeType, null, eName.get(), eNs.get(), false, XmlFormat.DEFAULT, isMixed, false, null);
564         previousWasTextNode.set(currentIsTextNode);
565      });
566
567      return out;
568   }
569
570   private XmlWriter<?> writeStreamable(XmlWriter<?> out, Object in, ClassMeta<?> sType, ClassMeta<?> eType, BeanPropertyMeta ppMeta, boolean isMixed) throws SerializeException {
571
572      var eeType = eType.getElementType();
573
574      var eName = Holder.<String>empty();
575      var eNs = Holder.<Namespace>empty();
576
577      if (nn(ppMeta)) {
578         XmlBeanPropertyMeta bpXml = getXmlBeanPropertyMeta(ppMeta);
579         eName.set(bpXml.getChildName());
580         eNs.set(bpXml.getNamespace());
581      }
582
583      var previousWasTextNode = Holder.of(false);
584
585      forEachStreamableEntry(in, sType, x -> {
586         var currentIsTextNode = isTextNode(x);
587
588         if (isTrue(previousWasTextNode.get()) && currentIsTextNode && nn(textNodeDelimiter) && ! textNodeDelimiter.isEmpty()) {
589            out.append(textNodeDelimiter);
590         }
591
592         writeAnything(out, x, eeType, null, eName.get(), eNs.get(), false, XmlFormat.DEFAULT, isMixed, false, null);
593         previousWasTextNode.set(currentIsTextNode);
594      });
595
596      return out;
597   }
598
599   private ContentResult writeMap(XmlWriter<?> out, Map m, ClassMeta<?> sType, ClassMeta<?> eKeyType, ClassMeta<?> eValueType, boolean isMixed) throws SerializeException {
600
601      var keyType = eKeyType == null ? sType.getKeyType() : eKeyType;
602      var valueType = eValueType == null ? sType.getValueType() : eValueType;
603
604      var hasChildren = Flag.create();
605      forEachEntry(m, e -> {
606
607         var k = e.getKey();
608         if (k == null) {
609            k = "\u0000";
610         } else {
611            k = generalize(k, keyType);
612            if (isTrimStrings() && k instanceof String)
613               k = k.toString().trim();
614         }
615
616         var value = e.getValue();
617
618         hasChildren.ifNotSet(() -> out.w('>').nlIf(! isMixed, indent)).set();
619         writeAnything(out, value, valueType, toString(k), null, null, false, XmlFormat.DEFAULT, isMixed, false, null);
620      });
621
622      return hasChildren.isSet() ? CR_ELEMENTS : CR_EMPTY;
623   }
624
625   @Override /* Overridden from Serializer */
626   protected void doWrite(SerializerPipe out, Object o) throws IOException, SerializeException {
627      if (isEnableNamespaces() && isAutoDetectNamespaces())
628         findNsfMappings(o);
629      writeAnything(getXmlWriter(out), o, getExpectedRootType(o), null, null, null, isEnableNamespaces() && isAddNamespaceUrisToRoot(), XmlFormat.DEFAULT, false, false, null);
630   }
631
632   /**
633    * Opens a whole-value push generator targeting XML output, bound to this live session.
634    *
635    * <p>
636    * Because XML's wire format (attributes, namespaces, mixed content) is non-trivial to expose
637    * as a fine-grained token writer, this implementation supports only the cursor-level POJO
638    * bridge &mdash; {@link RecordWriter#write(Object) write(Object)} delegates to
639    * {@link SerializerSession#write(Object, Object)}.
640    *
641    * @param output The output.
642    * @return A new {@link RecordWriter}.
643    * @throws IOException If a problem occurred opening the underlying output.
644    */
645   @Override /* RecordWritable */
646   public RecordWriter writeRecords(Object output) throws IOException {
647      return RecordAdapter.writer(this, output);
648   }
649
650   /**
651    * The XML record writer is buffered/{@link RecordAdapter}-backed, not O(1) streaming.
652    *
653    * @return Always <jk>false</jk>.
654    */
655   @Override /* RecordWritable */
656   public boolean isRecordStreaming() { return false; }
657
658   /**
659    * Recursively searches for the XML namespaces on the specified POJO and adds them to the serializer context object.
660    *
661    * @param o The POJO to check.
662    * @throws SerializeException Thrown if bean recursion occurred.
663    */
664   protected final void findNsfMappings(Object o) throws SerializeException {
665      ClassMeta<?> aType = null;                // The actual type
666
667      try {
668         aType = push(null, o, null);
669      } catch (MarshallingRecursionException e) {
670         throw new SerializeException(e);
671      }
672
673      if (nn(aType)) {
674         var ns = getXmlClassMeta(aType).getNamespace();
675         if (nn(ns) && nn(ns.uri))
676            addNamespace(ns);
677      }
678
679      // Handle recursion
680      if (nn(aType) && ! aType.isPrimitive()) {
681
682         BeanMap<?> bm = null;
683         if (aType.isBeanMap()) {
684            bm = (BeanMap<?>)o;
685         } else if (aType.isBean()) {
686            bm = toBeanMap(o);
687         } else if (aType.isDelegate()) {
688            var innerType = (ClassMeta<?>)((Delegate<?>)o).getBeanInfo();
689            var ns = Holder.of(getXmlClassMeta(innerType).getNamespace());
690            if (ns.isPresent()) {
691               if (nn(ns.get().uri))
692                  addNamespace(ns.get());
693               else
694                  ns.getAndUnset();
695            }
696
697            if (innerType.isBean()) {
698               innerType.getBeanMeta().getProperties().values().stream().filter(BeanPropertyMeta::canRead).forEach(x -> {
699                  ns.set(getXmlBeanPropertyMeta(x).getNamespace());
700                  if (ns.isPresent() && nn(ns.get().uri))
701                     addNamespace(ns.get());
702               });
703            } else if (innerType.isMap()) {
704               ((Map<?,?>)o).forEach((k, v) -> findNsfMappings(v));
705            } else if (innerType.isCollection()) {
706               ((Collection<?>)o).forEach(this::findNsfMappings);
707            }
708
709         } else if (aType.isMap()) {
710            ((Map<?,?>)o).forEach((k, v) -> findNsfMappings(v));
711         } else if (aType.isCollection()) {
712            ((Collection<?>)o).forEach(this::findNsfMappings);
713         } else if (aType.isArray() && ! aType.getElementType().isPrimitive()) {
714            for (var o2 : ((Object[])o))
715               findNsfMappings(o2);
716         }
717         if (nn(bm)) {
718            var checkNull = (Predicate<Object>)(x -> isKeepNullProperties() || nn(x));
719            bm.forEachValue(checkNull, (pMeta, key, value, thrown) -> {
720               var ns = getXmlBeanPropertyMeta(pMeta).getNamespace();
721               if (nn(ns) && nn(ns.uri))
722                  addNamespace(ns);
723
724               try {
725                  findNsfMappings(value);
726               } catch (@SuppressWarnings("unused") Exception x) {
727                  // Ignore
728               }
729            });
730         }
731      }
732
733      pop();
734   }
735
736   /**
737    * Default namespace.
738    *
739    * @see XmlSerializer.Builder#defaultNamespace(Namespace)
740    * @return
741    *    The default namespace URI for this document.
742    */
743   protected final Namespace getDefaultNamespace() { return defaultNamespace; }
744
745   /**
746    * Default namespaces.
747    *
748    * @see XmlSerializer.Builder#namespaces(Namespace...)
749    * @return
750    *    The default list of namespaces associated with this serializer.
751    *    <br>Never <jk>null</jk>.
752    *    <br>List is modifiable (namespaces can be added during serialization).
753    */
754   protected final List<Namespace> getNamespaces() { return namespaces == null ? l() : namespaces; }
755
756   @Override
757   public boolean isAddBeanTypes() { return super.isAddBeanTypes(); }
758
759   /**
760    * Add JSON type tags.
761    *
762    * @see XmlSerializer.Builder#disableJsonTags()
763    * @return
764    *    <jk>true</jk> if plain strings will be wrapped in <js>&lt;string&gt;</js> tags when serialized as root elements.
765    */
766   protected final boolean isAddJsonTags() { return ctx.addJsonTags; }
767
768   /**
769    * Add namespace URLs to the root element.
770    *
771    * @see XmlSerializer.Builder#addNamespaceUrisToRoot()
772    * @return
773    *    <jk>true</jk> if {@code xmlns:x} attributes are added to the root element for the default and all mapped namespaces.
774    */
775   protected final boolean isAddNamespaceUrisToRoot() { return addNamespaceUrisToRoot; }
776
777   /**
778    * Auto-detect namespace usage.
779    *
780    * @see XmlSerializer.Builder#disableAutoDetectNamespaces()
781    * @return
782    *    <jk>true</jk> if namespace usage is detected before serialization.
783    */
784   protected final boolean isAutoDetectNamespaces() { return autoDetectNamespaces; }
785
786   /**
787    * Enable support for XML namespaces.
788    *
789    * @see XmlSerializer.Builder#enableNamespaces()
790    * @return
791    *    <jk>false</jk> if XML output will not contain any namespaces regardless of any other settings.
792    */
793   protected final boolean isEnableNamespaces() { return enableNamespaces; }
794
795   /**
796    * Returns <jk>true</jk> if we're serializing HTML.
797    *
798    * <p>
799    * The difference in behavior is how empty non-void elements are handled.
800    * The XML serializer will produce a collapsed tag, whereas the HTML serializer will produce a start and end tag.
801    *
802    * @return <jk>true</jk> if we're generating HTML.
803    */
804   protected boolean isHtmlMode() { return false; }
805
806   /**
807    * Workhorse method.
808    *
809    * @param out The writer to send the output to.
810    *    <br>Must not be <jk>null</jk>.
811    * @param o The object to serialize.
812    *    <br>Can be <jk>null</jk> (written as the XML null representation, e.g. <c>type='null'</c>).
813    * @param eType The expected type if this is a bean property value being serialized.
814    *    <br>Can be <jk>null</jk> (treated as the generic {@link Object} type).
815    * @param keyName The property name or map key name.
816    *    <br>Can be <jk>null</jk> (the element is written without a separate property/key name).
817    * @param elementName The root element name.
818    *    <br>Can be <jk>null</jk> (falls back to the bean dictionary name, then <c>keyName</c>, then the JSON type name).
819    * @param elementNamespace The namespace of the element.
820    *    <br>Can be <jk>null</jk> (resolved from the serialized type's, then the actual type's, then the session's default namespace).
821    * @param addNamespaceUris Flag indicating that namespace URIs need to be added.
822    * @param format The format to serialize the output to.
823    *    <br>Must not be <jk>null</jk>.
824    * @param isMixedOrText We're serializing mixed content, so don't use whitespace.
825    * @param preserveWhitespace
826    *    <jk>true</jk> if we're serializing {@link XmlFormat#MIXED_PWS} or {@link XmlFormat#TEXT_PWS}.
827    * @param pMeta The bean property metadata if this is a bean property being serialized.
828    *    <br>Can be <jk>null</jk> (no bean-property-level URI/child-name/namespace metadata is applied).
829    * @return The same writer passed in so that calls to the writer can be chained.
830    * @throws SerializeException General serialization error occurred.
831    */
832   @SuppressWarnings({
833      "java:S107" // Method has many parameters; acceptable for builder/configuration methods
834   })
835   protected ContentResult writeAnything(XmlWriter<?> out, Object o, ClassMeta<?> eType, String keyName, String elementName, Namespace elementNamespace, boolean addNamespaceUris, XmlFormat format,
836      boolean isMixedOrText, boolean preserveWhitespace, BeanPropertyMeta pMeta) throws SerializeException {
837
838      JsonType type = null;              // The type string (e.g. <type> or <x x='type'>
839      int i = isMixedOrText ? 0 : indent;       // Current indentation
840      ClassMeta<?> aType = null;     // The actual type
841      ClassMeta<?> wType = null;     // The wrapped type (delegate)
842      ClassMeta<?> sType; // The serialized type
843
844      aType = push2(keyName, o, eType);
845
846      if (eType == null)
847         eType = object();
848
849      // Handle recursion
850      if (aType == null) {
851         o = null;
852         aType = object();
853      }
854
855      // Handle Optional<X>
856      if (isOptional(aType)) {
857         o = getOptionalValue(o);
858         eType = getOptionalType(eType);
859         aType = getClassMetaForObject(o, object());
860      }
861
862      if (nn(o)) {
863
864         if (aType.isDelegate()) {
865            wType = aType;
866            eType = aType = (ClassMeta<?>)((Delegate<?>)o).getBeanInfo();
867         }
868
869         sType = aType;
870
871         // Swap if necessary
872         var swap = aType.getSwap(this);
873         if (nn(swap)) {
874            o = swap(swap, o);
875            sType = swap.getSwapClassMeta(this);
876
877            // If the getSwapClass() method returns Object, we need to figure out
878            // the actual type now.
879            if (sType.isObject())
880               sType = getClassMetaForObject(o);
881         }
882      } else {
883         sType = eType.getSerializedClassMeta(this);
884      }
885
886      // Does the actual type match the expected type?
887      boolean isExpectedType = true;
888      if (o == null || ! eType.same(aType)) {
889         if (eType.isNumber())
890            isExpectedType = aType.isNumber();
891         else if (eType.isMap())
892            isExpectedType = aType.isMap();
893         else if (eType.isCollectionOrArray() || eType.isStreamable())
894            isExpectedType = aType.isCollectionOrArray() || aType.isStreamable();
895         else
896            isExpectedType = false;
897      }
898
899      var resolvedDictionaryName = isExpectedType ? null : aType.getBeanDictionaryName();
900
901      // Note that the dictionary name may be specified on the actual type or the serialized type.
902      // HTML templates will have them defined on the serialized type.
903      var dictionaryName = aType.getBeanDictionaryName();
904      if (dictionaryName == null)
905         dictionaryName = sType.getBeanDictionaryName();
906
907      // char '\0' is interpreted as null.
908      if (nn(o) && sType.isChar() && ((Character)o).charValue() == 0)
909         o = null;
910
911      boolean isCollapsed = false;     // If 'true', this is a collection and we're not rendering the outer element.
912      boolean isRaw = (sType.isReader() || sType.isInputStream() || o instanceof RawContent) && nn(o);
913
914      // Get the JSON type string.
915      if (o == null) {
916         type = NULL;
917      } else if (sType.isCharSequence() || sType.isChar()) {
918         type = STRING;
919      } else if (sType.isNumber()) {
920         type = NUMBER;
921      } else if (sType.isBoolean()) {
922         type = BOOLEAN;
923      } else if (sType.isMapOrBean()) {
924         isCollapsed = getXmlClassMeta(sType).getFormat() == COLLAPSED;
925         type = OBJECT;
926      } else if (sType.isCollectionOrArray() || sType.isStreamable()) {
927         isCollapsed = (format == COLLAPSED && ! addNamespaceUris);
928         type = ARRAY;
929      } else {
930         type = STRING;
931      }
932
933      if (format.isOneOf(MIXED, MIXED_PWS, TEXT, TEXT_PWS, XMLTEXT, RAWTEXT) && type.isOneOf(NULL, STRING, NUMBER, BOOLEAN))
934         isCollapsed = true;
935
936      // Is there a name associated with this bean?
937
938      var name = keyName;
939      if (elementName == null && nn(dictionaryName)) {
940         elementName = dictionaryName;
941         isExpectedType = nn(o);  // preserve type='null' when it's null.
942      }
943
944      if (elementName == null) {
945         elementName = name;
946         name = null;
947      }
948
949      if (eq(name, elementName))
950         name = null;
951
952      if (isEnableNamespaces()) {
953         if (elementNamespace == null)
954            elementNamespace = getXmlClassMeta(sType).getNamespace();
955         if (elementNamespace == null)
956            elementNamespace = getXmlClassMeta(aType).getNamespace();
957         if (elementNamespace == null)
958            elementNamespace = defaultNamespace;
959      } else {
960         elementNamespace = null;
961      }
962
963      // Do we need a carriage return after the start tag?
964      boolean cr = nn(o) && (sType.isMapOrBean() || sType.isCollectionOrArray()) && ! isMixedOrText;
965
966      var en = elementName;
967      if (en == null && ! isRaw && isAddJsonTags()) {
968         en = type.toString();
969         type = null;
970      }
971
972      boolean encodeEn = nn(elementName);
973      var ns = (elementNamespace == null ? null : elementNamespace.name);
974      String dns = null;
975      String elementNs = null;
976      if (isEnableNamespaces()) {
977         dns = elementName == null && nn(defaultNamespace) ? defaultNamespace.name : null;
978         elementNs = elementName == null ? dns : ns;
979         if (elementName == null)
980            elementNamespace = null;
981      }
982
983      // Render the start tag.
984      if (! isCollapsed) {
985         if (nn(en)) {
986            out.oTag(i, elementNs, en, encodeEn);
987            if (addNamespaceUris) {
988               out.attr((String)null, "xmlns", defaultNamespace.getUri());
989
990               for (var n : namespaces)
991                  out.attr("xmlns", n.getName(), n.getUri());
992            }
993            if (! isExpectedType) {
994               if (nn(resolvedDictionaryName))
995                  out.attr(dns, getBeanTypePropertyName(eType), resolvedDictionaryName);
996               else if (nn(type) && type != STRING)
997                  out.attr(dns, getBeanTypePropertyName(eType), type);
998            }
999            if (nn(name))
1000               out.attr(MarshallingSession.NAME_PROPERTY_NAME, name);
1001         } else {
1002            out.i(i);
1003         }
1004         if (o == null && (sType.isBoolean() || sType.isNumber()) && ! sType.isNullable())
1005            o = sType.getPrimitiveDefault();
1006
1007         if (nn(o) && ! (sType.isMapOrBean() || en == null))
1008            out.w('>');
1009
1010         if (cr && ! (sType.isMapOrBean()))
1011            out.nl(i + 1);
1012      }
1013
1014      var rc = CR_ELEMENTS;
1015
1016      // Render the tag contents.
1017      if (nn(o)) {
1018         if (o instanceof RawContent) {
1019            // Re-serializable, String-backed raw content: emit verbatim (no entity/whitespace encoding), like the Reader path.
1020            out.append(o.toString());
1021         } else if (sType.isBean()) {
1022            rc = writeBeanMap(out, toBeanMap(o), elementNamespace, isCollapsed, isMixedOrText);
1023         } else if (sType.isMap() || (nn(wType) && wType.isMap())) {
1024            if (o instanceof BeanMap o2)
1025               rc = writeBeanMap(out, o2, elementNamespace, isCollapsed, isMixedOrText);
1026            else
1027               rc = writeMap(out, (Map)o, sType, eType.getKeyType(), eType.getValueType(), isMixedOrText);
1028         } else if (sType.isCollection() || sType.isArray() || (nn(wType) && (wType.isCollection() || wType.isArray()))) {
1029            if (isCollapsed)
1030               indent--;
1031            writeCollection(out, o, sType, eType, pMeta, isMixedOrText);
1032            if (isCollapsed)
1033               indent++;
1034         } else if (sType.isUri() || (nn(pMeta) && pMeta.isUri())) {
1035            // Must come before isCharSequence/isChar: @Uri-annotated String properties (where sType is String but pMeta.isUri() is true) need to route here for URI text emission.
1036            out.textUri(o);
1037         } else if (sType.isCharSequence() || sType.isChar()) {
1038            if (isXmlText(format, sType) || isRawText(format, sType))
1039               out.append(o);
1040            else
1041               out.text(o, preserveWhitespace);
1042         } else if (sType.isNumber() || sType.isBoolean()) {
1043            out.append(o);
1044         } else if (sType.isDate()) {
1045            out.text(writeDate((Date)o, sType));
1046         } else if (sType.isCalendar()) {
1047            out.text(writeCalendar(o, sType));
1048         } else if (sType.isTemporal()) {
1049            out.text(writeTemporal((TemporalAccessor)o, sType));
1050         } else if (sType.isDuration()) {
1051            out.text(writeDuration((Duration)o));
1052         } else if (sType.isPeriod()) {
1053            out.text(writePeriod((Period)o));
1054         } else if (sType.isStreamable()) {
1055            if (isCollapsed)
1056               indent--;
1057            writeStreamable(out, o, sType, eType, pMeta, isMixedOrText);
1058            if (isCollapsed)
1059               indent++;
1060         } else if (sType.isReader()) {
1061            pipe((Reader)o, out, SerializerSession::handleThrown);
1062         } else if (sType.isInputStream()) {
1063            pipe((InputStream)o, out, SerializerSession::handleThrown);
1064         } else {
1065            if (isXmlText(format, sType) || isRawText(format, sType))
1066               out.append(toString(o));
1067            else
1068               out.text(toString(o));
1069         }
1070      }
1071
1072      pop();
1073
1074      // Render the end tag.
1075      if (! isCollapsed) {
1076         if (nn(en)) {
1077            if (rc == CR_EMPTY) {
1078               if (isHtmlMode())
1079                  out.w('>').eTag(elementNs, en, encodeEn);
1080               else
1081                  out.w('/').w('>');
1082            } else if (rc == CR_VOID || o == null) {
1083               out.w('/').w('>');
1084            } else
1085               out.ie(cr && rc != CR_MIXED ? i : 0).eTag(elementNs, en, encodeEn);
1086         }
1087         if (! isMixedOrText)
1088            out.nl(i);
1089      }
1090
1091      return rc;
1092   }
1093}