This is an automated email from the ASF dual-hosted git repository.
jamesbognar pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/juneau.git
The following commit(s) were added to refs/heads/master by this push:
new 3044ef4a2c Improved CSV support
3044ef4a2c is described below
commit 3044ef4a2c1ac33ff5a40cf324bcf812a938ea24
Author: James Bognar <[email protected]>
AuthorDate: Sun Mar 1 08:43:59 2026 -0500
Improved CSV support
---
AGENTS.md | 14 +-
RELEASE-NOTES.txt | 9 +
.../src/main/java/org/apache/juneau/ClassMeta.java | 3 +-
.../src/main/java/org/apache/juneau/Context.java | 2 +-
.../org/apache/juneau/csv/ByteArrayFormat.java | 34 +++
.../java/org/apache/juneau/csv/CsvCellParser.java | 218 ++++++++++++++++++
.../org/apache/juneau/csv/CsvCellSerializer.java | 233 +++++++++++++++++++
.../main/java/org/apache/juneau/csv/CsvParser.java | 122 ++++++++--
.../org/apache/juneau/csv/CsvParserSession.java | 172 +++++++++++++-
.../main/java/org/apache/juneau/csv/CsvReader.java | 29 ++-
.../java/org/apache/juneau/csv/CsvSerializer.java | 122 ++++++++--
.../apache/juneau/csv/CsvSerializerSession.java | 251 +++++++++++++++++++--
.../html/HtmlStrippedDocSerializerSession.java | 3 +
.../juneau/oapi/OpenApiSerializerSession.java | 1 -
.../org/apache/juneau/svl/ResolvingJsonMap.java | 2 +-
.../apache/juneau/xml/XmlSerializerSession.java | 5 +
.../java/org/apache/juneau/yaml/YamlParser.java | 3 -
.../org/apache/juneau/yaml/YamlParserSession.java | 196 +++++++++-------
.../org/apache/juneau/yaml/YamlSerializer.java | 1 -
.../java/org/apache/juneau/yaml/YamlWriter.java | 2 +-
.../juneau/a/rttests/RoundTripTest_Base.java | 43 ++--
.../java/org/apache/juneau/csv/CsvParser_Test.java | 2 +-
.../test/java/org/apache/juneau/csv/Csv_Test.java | 234 ++++++++++++++++++-
23 files changed, 1502 insertions(+), 199 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 6299dfed45..06f2f46edf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -18,13 +18,21 @@ This document outlines the rules, guidelines, and best
practices that AI assista
### Task Interpretation Commands
- **"make a plan"** or **"come up with a plan"** - When the user asks to make
a plan for something, provide a summary of suggested changes only. **Do NOT
make actual code changes**. The plan should outline what needs to be done, but
implementation should wait for explicit user approval.
- **"suppress warnings"** or **"suppress issues"** - When the user asks to
suppress warnings or issues that appear to refer to SonarLint/SonarQube issues,
add `@SuppressWarnings` annotations to the appropriate class or method level.
Use the specific rule ID from the warning (e.g., `java:S100`, `java:S115`,
`java:S116`). Apply class-level suppressions when multiple methods/fields are
affected, or method-level suppressions for specific methods.
-- **SuppressWarnings Format**: When adding `@SuppressWarnings` annotations,
use this format:
+- **SuppressWarnings Format**: When adding `@SuppressWarnings` annotations,
always use multi-line format with curly braces and include a brief comment
explaining why:
```java
@SuppressWarnings({
- "java:Sxxx" // Short reason why
+ "resource" // CsvReader owns ParserReader; caller must close CsvReader
})
```
- Use the multi-line format with curly braces and include a brief comment
explaining why the suppression is needed.
+ Or for multiple suppressions:
+ ```java
+ @SuppressWarnings({
+ "rawtypes", // Raw types necessary for generic type handling
+ "unchecked", // Type erasure requires unchecked casts
+ "resource" // w is closed by try-with-resources; lambdas capture it
+ })
+ ```
+ Never use single-line `@SuppressWarnings("xxx")` without the multi-line
format and comment.
### Script Shortcut Commands
- **"start docs"** or **"start docusaurus"** - Runs
`scripts/start-docusaurus.py`
diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index d8060c3256..837d2d3916 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -13,6 +13,15 @@
Release Notes - Juneau - Version 9.2.1 - YYYY-MM-DD
+** New Features - CSV Marshalling Parity with JSON
+
+ * Type discriminator: addBeanTypes().addRootType() adds _type column for
polymorphic parsing.
+ * Byte arrays: byteArrayFormat(BASE64) or SEMICOLON_DELIMITED; primitive
arrays as [1;2;3].
+ * Nested structures: allowNestedStructures(true) enables inline {key:val}
and [val;val] in cells.
+ * Null marker: nullValue("<NULL>") (default) for unambiguous null
serialization/parsing.
+ * New classes: CsvCellParser, CsvCellSerializer, ByteArrayFormat.
+ * New builder options: byteArrayFormat(), allowNestedStructures(),
nullValue().
+
** Changes
* Bump junit.version from a mix of 5.13.4 and 6.0.1 to 6.0.3.
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ClassMeta.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ClassMeta.java
index 95f25d1384..4f41cc762e 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ClassMeta.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/ClassMeta.java
@@ -1440,7 +1440,8 @@ public class ClassMeta<T> extends ClassInfoTyped<T> {
}
@SuppressWarnings({
- "unchecked" // Type erasure requires cast for
List<ObjectSwap<T,?>>
+ "unchecked", // Type erasure requires cast for
List<ObjectSwap<T,?>>
+ "java:S3776" // Cognitive complexity acceptable for swap
resolution logic
})
private List<ObjectSwap<T,?>> findSwaps() {
if (beanContext == null)
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java
index a6a62400fa..585873086e 100644
--- a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java
+++ b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/Context.java
@@ -483,7 +483,7 @@ public abstract class Context {
* @param x The object to traverse (Class, ClassInfo, Method,
or MethodInfo).
* @return The work list.
*/
- private AnnotationWorkList traverse(AnnotationWorkList work,
Object x) {
+ private static AnnotationWorkList traverse(AnnotationWorkList
work, Object x) {
var ap = AP;
CollectionUtils.traverse(x, y -> {
if (x instanceof Class<?> x2)
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/ByteArrayFormat.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/ByteArrayFormat.java
new file mode 100644
index 0000000000..66e513885f
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/ByteArrayFormat.java
@@ -0,0 +1,34 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.csv;
+
+/**
+ * Format for serializing {@code byte[]} arrays in CSV cells.
+ *
+ * <ul>
+ * <li>{@link #BASE64} — Base64 encoding (e.g. {@code SGVsbG8gV29ybGQ=}).
Matches JSON default.
+ * <li>{@link #SEMICOLON_DELIMITED} — Semicolon-delimited decimal byte
values (e.g. {@code 72;101;108;108;111}).
+ * </ul>
+ */
+public enum ByteArrayFormat {
+
+ /** Base64 encoding. */
+ BASE64,
+
+ /** Semicolon-delimited decimal byte values. */
+ SEMICOLON_DELIMITED
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvCellParser.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvCellParser.java
new file mode 100644
index 0000000000..ae3f610fb1
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvCellParser.java
@@ -0,0 +1,218 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.csv;
+
+import java.util.*;
+
+import org.apache.juneau.parser.*;
+
+/**
+ * Parses CSV cell inline notation: {@code {key:val;key2:val2}} and {@code
[val1;val2;val3]}.
+ *
+ * <p>
+ * Grammar (CSV-specific, semicolon-separated):
+ * <ul>
+ * <li>Object: {@code \{ (key ':' value (';' key ':' value)*)? \}}
+ * <li>Array: {@code \[ (value (';' value)*)? \]}
+ * <li>Values: object | array | quoted string | number | identifier | null
+ * </ul>
+ *
+ * <h5 class='section'>Notes:</h5><ul>
+ * <li class='note'>Reserved characters: {@code ; : \{ \} [ ] " \}
+ * </ul>
+ */
+public final class CsvCellParser {
+
+ private final String input;
+ private final String nullMarker;
+ private int pos;
+ private int length;
+
+ private CsvCellParser(String input, String nullMarker) {
+ this.input = input != null ? input.trim() : "";
+ this.nullMarker = nullMarker != null ? nullMarker : "null";
+ this.length = this.input.length();
+ }
+
+ /**
+ * Parses a cell value string into a Map, List, or scalar.
+ *
+ * @param cell The cell string (e.g. {@code {a:1;b:2}} or {@code
[1;2;3]}).
+ * @param nullMarker The string that denotes null (e.g. {@code <NULL>}
or {@code null}).
+ * @return Parsed object: Map, List, String, Number, Boolean, or null.
+ * @throws ParseException If the input is invalid.
+ */
+ public static Object parse(String cell, String nullMarker) throws
ParseException {
+ if (cell == null || cell.isEmpty())
+ return null;
+ var p = new CsvCellParser(cell, nullMarker);
+ try {
+ return p.parseValue();
+ } catch (Exception e) {
+ throw new ParseException(e, "Invalid CSV cell notation
at position {0}: ''{1}''", p.pos, cell);
+ }
+ }
+
+ private void skipWhitespace() {
+ while (pos < length &&
Character.isWhitespace(input.charAt(pos)))
+ pos++;
+ }
+
+ private boolean hasMore() {
+ skipWhitespace();
+ return pos < length;
+ }
+
+ private char peek() {
+ return pos < length ? input.charAt(pos) : '\0';
+ }
+
+ private char consume() {
+ return pos < length ? input.charAt(pos++) : '\0';
+ }
+
+ private Object parseValue() throws ParseException {
+ skipWhitespace();
+ if (pos >= length)
+ return null;
+ var c = peek();
+ if (c == '{')
+ return parseObject();
+ if (c == '[')
+ return parseArray();
+ if (c == '"')
+ return parseQuotedString();
+ return parseSimpleValue();
+ }
+
+ private Map<String, Object> parseObject() throws ParseException {
+ if (consume() != '{')
+ throw new ParseException("Expected opening brace");
+ var m = new LinkedHashMap<String, Object>();
+ skipWhitespace();
+ if (peek() == '}') {
+ consume();
+ return m;
+ }
+ while (hasMore()) {
+ var key = parseKey();
+ skipWhitespace();
+ if (consume() != ':')
+ throw new ParseException("Expected ':' after
key at position " + pos);
+ var value = parseValue();
+ m.put(key, value);
+ skipWhitespace();
+ var c = peek();
+ if (c == '}') {
+ consume();
+ break;
+ }
+ if (c != ';')
+ throw new ParseException("Expected ';' or '}'
at position " + pos);
+ consume(); // skip ';'
+ }
+ return m;
+ }
+
+ private String parseKey() throws ParseException {
+ skipWhitespace();
+ if (pos >= length)
+ throw new ParseException("Unexpected end of input in
key");
+ if (peek() == '"')
+ return parseQuotedString();
+ return parseIdentifier();
+ }
+
+ private List<Object> parseArray() throws ParseException {
+ if (consume() != '[')
+ throw new ParseException("Expected opening bracket");
+ var list = new ArrayList<>();
+ skipWhitespace();
+ if (peek() == ']') {
+ consume();
+ return list;
+ }
+ while (hasMore()) {
+ list.add(parseValue());
+ skipWhitespace();
+ var c = peek();
+ if (c == ']') {
+ consume();
+ break;
+ }
+ if (c != ';')
+ throw new ParseException("Expected ';' or ']'
at position " + pos);
+ consume(); // skip ';'
+ }
+ return list;
+ }
+
+ private String parseQuotedString() throws ParseException {
+ if (consume() != '"')
+ throw new ParseException("Expected quote");
+ var sb = new StringBuilder();
+ while (pos < length) {
+ var c = consume();
+ if (c == '"')
+ return sb.toString();
+ if (c == '\\' && pos < length)
+ sb.append(consume()); // escaped char
+ else
+ sb.append(c);
+ }
+ throw new ParseException("Unterminated quoted string");
+ }
+
+ private String parseIdentifier() {
+ var start = pos;
+ while (pos < length) {
+ var c = input.charAt(pos);
+ if (c == ';' || c == ':' || c == '{' || c == '}' || c
== '[' || c == ']' || c == '"' || Character.isWhitespace(c))
+ break;
+ pos++;
+ }
+ return input.substring(start, pos);
+ }
+
+ private Object parseSimpleValue() throws ParseException {
+ skipWhitespace();
+ if (pos >= length || peek() == ';' || peek() == '}' || peek()
== ']')
+ return "";
+ var id = parseIdentifier();
+ if (id.isEmpty())
+ throw new ParseException("Expected value at position "
+ pos);
+ if (id.equalsIgnoreCase(nullMarker))
+ return null;
+ if (id.equals("true"))
+ return Boolean.TRUE;
+ if (id.equals("false"))
+ return Boolean.FALSE;
+ // Try number
+ try {
+ if (id.contains(".")) {
+ var d = Double.parseDouble(id);
+ if (d == (long) d)
+ return Long.valueOf((long) d);
+ return Double.valueOf(d);
+ }
+ return Long.valueOf(id);
+ } catch (@SuppressWarnings("unused") NumberFormatException e) {
+ // Not a number, treat as string
+ return id;
+ }
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvCellSerializer.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvCellSerializer.java
new file mode 100644
index 0000000000..9419d708fa
--- /dev/null
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvCellSerializer.java
@@ -0,0 +1,233 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.juneau.csv;
+
+import static org.apache.juneau.commons.utils.StringUtils.*;
+
+import java.util.*;
+
+/**
+ * Serializes nested objects and arrays to CSV cell inline notation:
+ * {@code {key:val;key2:val2}} and {@code [val1;val2;val3]}.
+ *
+ * <p>
+ * Used when {@code allowNestedStructures()} is enabled on the serializer.
+ */
+public final class CsvCellSerializer {
+
+ private final ByteArrayFormat byteArrayFormat;
+ private final String nullMarker;
+
+ /**
+ * Creates a new serializer.
+ *
+ * @param byteArrayFormat Format for byte[] values.
+ * @param nullMarker String to use for null values.
+ */
+ public CsvCellSerializer(ByteArrayFormat byteArrayFormat, String
nullMarker) {
+ this.byteArrayFormat = byteArrayFormat != null ?
byteArrayFormat : ByteArrayFormat.BASE64;
+ this.nullMarker = nullMarker != null ? nullMarker : "null";
+ }
+
+ /**
+ * Serializes a value to inline notation.
+ *
+ * @param value The value to serialize.
+ * @param session The serializer session (for bean conversion, date
formatting, etc.).
+ * @return The serialized string.
+ */
+ public String serialize(Object value, CsvSerializerSession session) {
+ return serializeValue(value, session);
+ }
+
+ private String serializeValue(Object value, CsvSerializerSession
session) {
+ if (value == null)
+ return nullMarker;
+ if (value instanceof Map<?, ?> m)
+ return serializeMap(m, session);
+ if (value instanceof Collection<?> c)
+ return serializeCollection(c, session);
+ if (value instanceof Object[] a)
+ return serializeObjectArray(a, session);
+ if (value instanceof byte[] b)
+ return byteArrayFormat ==
ByteArrayFormat.SEMICOLON_DELIMITED ? formatByteArraySemicolon(b) :
base64Encode(b);
+ if (value instanceof int[] a) return formatIntArray(a);
+ if (value instanceof long[] a) return formatLongArray(a);
+ if (value instanceof double[] a) return formatDoubleArray(a);
+ if (value instanceof float[] a) return formatFloatArray(a);
+ if (value instanceof short[] a) return formatShortArray(a);
+ if (value instanceof boolean[] a) return formatBooleanArray(a);
+ if (value instanceof char[] a) return formatCharArray(a);
+ // Bean or simple: use session to convert/format
+ var prepared = session.prepareForInlineValue(value);
+ if (prepared instanceof Map) return serializeMap((Map<?, ?>)
prepared, session);
+ if (prepared instanceof Collection) return
serializeCollection((Collection<?>) prepared, session);
+ if (prepared instanceof Object[]) return
serializeObjectArray((Object[]) prepared, session);
+ return escapeIfNeeded(prepared.toString());
+ }
+
+ private String serializeMap(Map<?, ?> m, CsvSerializerSession session) {
+ var sb = new StringBuilder();
+ sb.append('{');
+ var first = true;
+ for (var e : m.entrySet()) {
+ if (!first) sb.append(';');
+ first = false;
+ var k = e.getKey();
+ var v = e.getValue();
+ sb.append(escapeIfNeeded(k != null ? k.toString() :
nullMarker));
+ sb.append(':');
+ sb.append(serializeValue(v, session));
+ }
+ sb.append('}');
+ return sb.toString();
+ }
+
+ private String serializeCollection(Collection<?> c,
CsvSerializerSession session) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ var first = true;
+ for (var v : c) {
+ if (!first) sb.append(';');
+ first = false;
+ sb.append(serializeValue(v, session));
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private String serializeObjectArray(Object[] a, CsvSerializerSession
session) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(serializeValue(a[i], session));
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private static String formatIntArray(int[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private static String formatLongArray(long[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private static String formatDoubleArray(double[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private static String formatFloatArray(float[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private static String formatShortArray(short[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private static String formatBooleanArray(boolean[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private static String formatCharArray(char[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append((int) a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+
+ private static String formatByteArraySemicolon(byte[] b) {
+ var sb = new StringBuilder();
+ for (var i = 0; i < b.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(b[i] & 0xff);
+ }
+ return sb.toString();
+ }
+
+ private static String escapeIfNeeded(String s) {
+ if (s == null)
+ return "";
+ for (var i = 0; i < s.length(); i++) {
+ var c = s.charAt(i);
+ if (c == ';' || c == ':' || c == '{' || c == '}' || c
== '[' || c == ']' || c == '"' || c == '\\')
+ return quoted(s);
+ }
+ return s;
+ }
+
+ private static String quoted(String s) {
+ var sb = new StringBuilder();
+ sb.append('"');
+ for (var i = 0; i < s.length(); i++) {
+ var c = s.charAt(i);
+ if (c == '"' || c == '\\')
+ sb.append('\\');
+ sb.append(c);
+ }
+ sb.append('"');
+ return sb.toString();
+ }
+}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvParser.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvParser.java
index 6d84768d0b..9cfcfccc04 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvParser.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvParser.java
@@ -36,32 +36,28 @@ import org.apache.juneau.parser.*;
* Parses RFC 4180-compliant CSV into collections of beans, maps, or simple
values. Each row becomes an
* element; the header row defines column names.
*
- * <h5 class='section'>Data Structures Incompatible with CSV (vs. JSON):</h5>
- * <p>
- * CSV is a flat, tabular format. Unlike {@link
org.apache.juneau.json.JsonParser JSON}, which
- * parses nested structures and supports type discriminators, CSV cannot
faithfully parse:
+ * <h5 class='section'>JSON Parity Features (opt-in):</h5>
+ * <ul>
+ * <li><b>Type discriminator</b> — Parse {@code _type} column when present;
use {@link #beanDictionary(Class[])
+ * beanDictionary()} for polymorphic types.
+ * <li><b>Byte arrays</b> — {@link #byteArrayFormat(ByteArrayFormat)
byteArrayFormat(BASE64)} or
+ * {@code SEMICOLON_DELIMITED}; primitive arrays from {@code [1;2;3]}.
+ * <li><b>Nested structures</b> — {@link #allowNestedStructures(boolean)
allowNestedStructures(true)}
+ * parses inline {@code {key:val}} and {@code [val;val]} in cells.
+ * <li><b>Null marker</b> — {@link #nullValue(String)
nullValue("<NULL>")} (default); cells
+ * matching this are parsed as null.
+ * </ul>
+ *
+ * <h5 class='section'>Data Structures Not Supported:</h5>
* <ul>
- * <li><b>Raw primitive arrays</b> ({@code byte[]}, {@code int[]}, etc.) —
Cells are strings; no
- * unambiguous encoding. <i>JSON</i> parses arrays and base64-encoded
bytes.
- * <li><b>Nested beans</b> — Cannot reconstruct bean- or map-valued
properties from flat columns.
- * <i>JSON</i> parses nested objects directly.
- * <li><b>Collections/arrays within beans</b> — Cannot parse {@code
List<X>},
- * {@code Map<K,V>}, or array properties from a single cell.
<i>JSON</i> parses arrays
- * and nested objects.
- * <li><b>Generic type parameters</b> — No type discriminator. <i>JSON</i>
uses {@code @type} when
- * configured with {@code addBeanTypes().addRootType()}.
- * <li><b>Parent/inherited properties</b> — Bean hierarchy cannot be
reconstructed.
- * <i>JSON</i> reconstructs flattened properties into the correct bean
hierarchy.
- * <li><b>Optional wrappers</b> — Round-trip may differ from tree formats.
<i>JSON</i> handles
- * Optional consistently.
- * <li><b>Interface/abstract types</b> — No discriminator to select
implementation. <i>JSON</i>
- * uses {@code @type} with {@code implClass} mappings.
+ * <li><b>Parent/inherited properties</b> — Bean hierarchy cannot be
reconstructed from flat columns.
+ * <li><b>Optional wrappers</b> — Round-trip may differ from tree formats.
* </ul>
*
* <h5 class='section'>Best Supported:</h5>
* <p>
* Parsing into {@link java.util.Collection} of flat beans or maps, or single
beans/maps with simple
- * property types (primitives, strings, numbers, enums, dates).
+ * property types (primitives, strings, numbers, enums, dates, byte arrays, or
nested structures when enabled).
*
* <h5 class='section'>Notes:</h5><ul>
* <li class='note'>This class is thread safe and reusable.
@@ -83,11 +79,17 @@ public class CsvParser extends ReaderParser implements
CsvMetaProvider {
private static final Cache<HashKey,CsvParser> CACHE =
Cache.of(HashKey.class, CsvParser.class).build();
+ private ByteArrayFormat byteArrayFormat;
+ private boolean allowNestedStructures;
+ private String nullValue;
+
/**
* Constructor, default settings.
*/
protected Builder() {
consumes("text/csv");
+ byteArrayFormat = ByteArrayFormat.BASE64;
+ nullValue = "<NULL>";
}
/**
@@ -98,6 +100,9 @@ public class CsvParser extends ReaderParser implements
CsvMetaProvider {
*/
protected Builder(Builder copyFrom) {
super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ byteArrayFormat = copyFrom.byteArrayFormat;
+ allowNestedStructures = copyFrom.allowNestedStructures;
+ nullValue = copyFrom.nullValue;
}
/**
@@ -108,6 +113,31 @@ public class CsvParser extends ReaderParser implements
CsvMetaProvider {
*/
protected Builder(CsvParser copyFrom) {
super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ byteArrayFormat = copyFrom.byteArrayFormat;
+ allowNestedStructures = copyFrom.allowNestedStructures;
+ nullValue = copyFrom.nullValue;
+ }
+
+ /**
+ * String that denotes null when parsing. Must match the
serializer's nullValue.
+ *
+ * @param value The null marker string.
+ * @return This object.
+ */
+ public Builder nullValue(String value) {
+ nullValue = value;
+ return this;
+ }
+
+ /**
+ * Enables parsing of inline {@code {key:val}} and {@code
[val;val]} notation in cells.
+ *
+ * @param value Whether to allow nested structures.
+ * @return This object.
+ */
+ public Builder allowNestedStructures(boolean value) {
+ allowNestedStructures = value;
+ return this;
}
@Override /* Overridden from Builder */
@@ -146,6 +176,20 @@ public class CsvParser extends ReaderParser implements
CsvMetaProvider {
return this;
}
+ /**
+ * Format for parsing {@code byte[]} arrays from CSV cells.
+ *
+ * <p>
+ * Must match the format used by the serializer. Default is
{@link ByteArrayFormat#BASE64}.
+ *
+ * @param value The format to use.
+ * @return This object.
+ */
+ public Builder byteArrayFormat(ByteArrayFormat value) {
+ byteArrayFormat = value;
+ return this;
+ }
+
@Override /* Overridden from Builder */
public Builder beanClassVisibility(Visibility value) {
super.beanClassVisibility(value);
@@ -290,6 +334,11 @@ public class CsvParser extends ReaderParser implements
CsvMetaProvider {
return this;
}
+ @Override /* Overridden from Context.Builder */
+ public HashKey hashKey() {
+ return HashKey.of(super.hashKey(), byteArrayFormat,
allowNestedStructures, nullValue);
+ }
+
@Override /* Overridden from Context.Builder */
public CsvParser build() {
return cache(CACHE).build(CsvParser.class);
@@ -633,6 +682,9 @@ public class CsvParser extends ReaderParser implements
CsvMetaProvider {
private final Map<ClassMeta<?>,CsvClassMeta> csvClassMetas = new
ConcurrentHashMap<>();
private final Map<BeanPropertyMeta,CsvBeanPropertyMeta>
csvBeanPropertyMetas = new ConcurrentHashMap<>();
+ private final ByteArrayFormat byteArrayFormat;
+ private final boolean allowNestedStructures;
+ private final String nullValue;
/**
* Constructor.
@@ -641,6 +693,36 @@ public class CsvParser extends ReaderParser implements
CsvMetaProvider {
*/
public CsvParser(Builder builder) {
super(builder);
+ byteArrayFormat = builder.byteArrayFormat != null ?
builder.byteArrayFormat : ByteArrayFormat.BASE64;
+ allowNestedStructures = builder.allowNestedStructures;
+ nullValue = builder.nullValue != null ? builder.nullValue :
"<NULL>";
+ }
+
+ /**
+ * Returns the format for parsing {@code byte[]} arrays from CSV cells.
+ *
+ * @return The byte array format.
+ */
+ public ByteArrayFormat getByteArrayFormat() {
+ return byteArrayFormat;
+ }
+
+ /**
+ * Returns whether inline {@code {key:val}} and {@code [val;val]}
notation parsing is enabled.
+ *
+ * @return Whether nested structures are allowed.
+ */
+ public boolean isAllowNestedStructures() {
+ return allowNestedStructures;
+ }
+
+ /**
+ * Returns the string that denotes null when parsing.
+ *
+ * @return The null marker.
+ */
+ public String getNullValue() {
+ return nullValue;
}
@Override /* Overridden from Context */
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvParserSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvParserSession.java
index 794a4296ad..3274be683c 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvParserSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvParserSession.java
@@ -17,6 +17,8 @@
package org.apache.juneau.csv;
import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.StringUtils.*;
+import static org.apache.juneau.commons.utils.Utils.*;
import java.io.*;
import java.lang.reflect.*;
@@ -59,11 +61,19 @@ import org.apache.juneau.swap.*;
})
public class CsvParserSession extends ReaderParserSession {
+ private final ByteArrayFormat byteArrayFormat;
+ private final boolean allowNestedStructures;
+ private final String nullValue;
+
/**
* Builder class.
*/
public static class Builder extends ReaderParserSession.Builder {
+ private ByteArrayFormat byteArrayFormat;
+ private boolean allowNestedStructures;
+ private String nullValue;
+
/**
* Constructor
*
@@ -71,6 +81,9 @@ public class CsvParserSession extends ReaderParserSession {
*/
protected Builder(CsvParser ctx) {
super(ctx);
+ byteArrayFormat = ctx.getByteArrayFormat();
+ allowNestedStructures = ctx.isAllowNestedStructures();
+ nullValue = ctx.getNullValue();
}
@Override /* Overridden from Builder */
@@ -192,6 +205,9 @@ public class CsvParserSession extends ReaderParserSession {
*/
protected CsvParserSession(Builder builder) {
super(builder);
+ byteArrayFormat = builder.byteArrayFormat;
+ allowNestedStructures = builder.allowNestedStructures;
+ nullValue = builder.nullValue != null ? builder.nullValue :
"<NULL>";
}
@Override /* Overridden from ParserSession */
@@ -291,6 +307,10 @@ public class CsvParserSession extends ReaderParserSession {
/**
* Parses a single data row into the appropriate element object.
+ *
+ * <p>
+ * Matches JSON behavior: for polymorphic types (interface/abstract
with dictionary), parse to map
+ * first then use {@link #cast(JsonMap, BeanPropertyMeta, ClassMeta)}
when <code>_type</code> is present.
*/
private Object parseRow(List<String> headers, List<String> row,
ClassMeta<?> eType, Object outer) throws ParseException {
if (eType == null || eType.isObject()) {
@@ -300,23 +320,53 @@ public class CsvParserSession extends ReaderParserSession
{
m.put(headers.get(i), parseCellValue(val,
object()));
}
return m;
- } else if (eType.isBean()) {
+ }
+ // Polymorphic (registry + _type column): parse to map then
cast - matches JSON
+ var typeColName = getBeanTypePropertyName(eType);
+ if (eType.getBeanRegistry() != null &&
headers.contains(typeColName)) {
+ var m = new JsonMap(this);
+ for (var i = 0; i < headers.size(); i++) {
+ var val = i < row.size() ? row.get(i) : null;
+ m.put(headers.get(i), parseCellValue(val,
object()));
+ }
+ if (m.containsKey(typeColName))
+ return cast(m, null, eType);
+ throw new ParseException(this, "Polymorphic type
''{0}'' requires _type column for resolution", eType);
+ }
+ if (eType.isBean()) {
return parseRowIntoBean(headers, row, eType, outer);
- } else if (eType.isMap()) {
+ }
+ if (eType.isMap()) {
return parseRowIntoMap(headers, row, eType, outer);
- } else {
- // Simple type: use the "value" column (first column)
or the only column present
- var val = row.isEmpty() ? null : row.get(0);
- return parseCellValue(val, eType);
}
+ // Simple type: use the "value" column (first column) or the
only column present
+ var val = row.isEmpty() ? null : row.get(0);
+ return parseCellValue(val, eType);
}
/**
* Parses a single data row into a bean of the specified type.
+ *
+ * <p>
+ * When a <code>_type</code> column (or configured type property) is
present with a non-empty
+ * value, the type name is resolved to a concrete class for polymorphic
bean creation.
*/
private <T> T parseRowIntoBean(List<String> headers, List<String> row,
ClassMeta<T> eType, Object outer) throws ParseException {
- var m = newBeanMap(outer, eType.inner());
+ var typeColName = getBeanTypePropertyName(eType);
+ var typeColIdx = headers.indexOf(typeColName);
+ ClassMeta<?> beanType = eType;
+ if (typeColIdx >= 0 && typeColIdx < row.size()) {
+ var typeVal = row.get(typeColIdx);
+ if (nn(typeVal) && ! typeVal.trim().isEmpty()) {
+ var resolved = getClassMeta(typeVal.trim(),
null, eType);
+ if (resolved != null && resolved.isBean())
+ beanType = resolved;
+ }
+ }
+ var m = newBeanMap(outer, beanType.inner());
for (var i = 0; i < headers.size(); i++) {
+ if (i == typeColIdx)
+ continue;
var header = headers.get(i);
var val = i < row.size() ? row.get(i) : null;
var pm = m.getPropertyMeta(header);
@@ -329,7 +379,7 @@ public class CsvParserSession extends ReaderParserSession {
onUnknownProperty(header, m, val);
}
}
- return m.getBean();
+ return (T) m.getBean();
}
/**
@@ -359,21 +409,119 @@ public class CsvParserSession extends
ReaderParserSession {
*
* <p>
* The unquoted literal {@code null} maps to Java {@code null}.
+ * Handles {@code byte[]} (BASE64 or semicolon-delimited) and primitive
arrays {@code [1;2;3]}.
* All other values are converted via {@link #convertToType(Object,
ClassMeta)}.
*/
private <T> T parseCellValue(String val, ClassMeta<T> eType) throws
ParseException {
- if (val == null || val.equals("null"))
+ if (val == null)
return null;
- // Apply trimStrings at the cell level (before type conversion)
so that the String
- // value passed to convertToType() is already trimmed.
- if (isTrimStrings() && val != null)
+ // Apply trimStrings at the cell level (before type conversion)
+ if (isTrimStrings())
val = val.trim();
+ if (nullValue != null && val.equals(nullValue))
+ return null;
if (val.isEmpty() && eType.isCharSequence())
return null;
try {
+ if (allowNestedStructures && !val.isEmpty()) {
+ var trimmed = isTrimStrings() ? val.trim() :
val;
+ if (trimmed.startsWith("{") ||
trimmed.startsWith("[")) {
+ var parsed =
CsvCellParser.parse(trimmed, nullValue);
+ if (parsed != null)
+ return convertToType(parsed,
eType);
+ }
+ }
+ var csvParsed = parseCsvCellValue(val, eType);
+ if (csvParsed != null)
+ return (T) csvParsed;
return convertToType(val, eType);
} catch (InvalidDataConversionException e) {
throw new ParseException(e, "Could not convert CSV cell
value ''{0}'' to type ''{1}''.", val, eType);
}
}
+
+ /**
+ * CSV-specific parsing for byte[] and primitive arrays. Returns null
if not applicable.
+ */
+ private Object parseCsvCellValue(String val, ClassMeta<?> eType) throws
ParseException {
+ if (val == null || val.isEmpty())
+ return null;
+ if (eType.isByteArray()) {
+ if (byteArrayFormat ==
ByteArrayFormat.SEMICOLON_DELIMITED) {
+ var parts = val.split(";");
+ var b = new byte[parts.length];
+ for (var i = 0; i < parts.length; i++) {
+ b[i] = (byte)
Integer.parseInt(parts[i].trim());
+ }
+ return b;
+ }
+ return base64Decode(val);
+ }
+ if (eType.isArray() && eType.getElementType().isPrimitive()) {
+ if (!val.startsWith("[") || !val.endsWith("]"))
+ return null;
+ var inner = val.substring(1, val.length() - 1).trim();
+ if (inner.isEmpty()) {
+ return createEmptyPrimitiveArray(eType);
+ }
+ var parts = inner.split(";");
+ var et = eType.getElementType();
+ if (et.is(int.class)) {
+ var a = new int[parts.length];
+ for (var i = 0; i < parts.length; i++)
+ a[i] =
Integer.parseInt(parts[i].trim());
+ return a;
+ }
+ if (et.is(long.class)) {
+ var a = new long[parts.length];
+ for (var i = 0; i < parts.length; i++)
+ a[i] = Long.parseLong(parts[i].trim());
+ return a;
+ }
+ if (et.is(double.class)) {
+ var a = new double[parts.length];
+ for (var i = 0; i < parts.length; i++)
+ a[i] =
Double.parseDouble(parts[i].trim());
+ return a;
+ }
+ if (et.is(float.class)) {
+ var a = new float[parts.length];
+ for (var i = 0; i < parts.length; i++)
+ a[i] =
Float.parseFloat(parts[i].trim());
+ return a;
+ }
+ if (et.is(short.class)) {
+ var a = new short[parts.length];
+ for (var i = 0; i < parts.length; i++)
+ a[i] =
Short.parseShort(parts[i].trim());
+ return a;
+ }
+ if (et.is(boolean.class)) {
+ var a = new boolean[parts.length];
+ for (var i = 0; i < parts.length; i++)
+ a[i] =
Boolean.parseBoolean(parts[i].trim());
+ return a;
+ }
+ if (et.is(char.class)) {
+ var a = new char[parts.length];
+ for (var i = 0; i < parts.length; i++)
+ a[i] = (char)
Integer.parseInt(parts[i].trim());
+ return a;
+ }
+ }
+ return null;
+ }
+
+ private static Object createEmptyPrimitiveArray(ClassMeta<?> arrayType)
throws ParseException {
+ var et = arrayType.getElementType();
+ if (et.is(int.class)) return new int[0];
+ if (et.is(long.class)) return new long[0];
+ if (et.is(double.class)) return new double[0];
+ if (et.is(float.class)) return new float[0];
+ if (et.is(short.class)) return new short[0];
+ if (et.is(boolean.class)) return new boolean[0];
+ if (et.is(char.class)) return new char[0];
+ if (et.is(byte.class)) return new byte[0];
+ return null;
+ }
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvReader.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvReader.java
index feb5f07780..5cb197b767 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvReader.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvReader.java
@@ -39,6 +39,9 @@ import org.apache.juneau.parser.*;
* <li class='note'>This class is not intended for external use.
* </ul>
*/
+@SuppressWarnings({
+ "resource" // CsvReader owns ParserReader; caller must close CsvReader
+})
public class CsvReader implements Closeable {
private final ParserReader r;
@@ -90,7 +93,11 @@ public class CsvReader implements Closeable {
* @throws IOException Thrown by underlying stream.
* @throws ParseException If the CSV is malformed (e.g. an unclosed
quoted field).
*/
- @SuppressWarnings("java:S3776")
+ @SuppressWarnings({
+ "java:S1168", // null = end of input (distinct from empty row)
+ "java:S3776", // Cognitive complexity acceptable for CSV row
parsing state machine
+ "java:S6541" // Brain method acceptable for CSV row parsing
state machine
+ })
public List<String> readRow() throws IOException, ParseException {
if (eof)
return null;
@@ -121,7 +128,7 @@ public class CsvReader implements Closeable {
if (c == -1) {
eof = true;
- fields.add(finalize(field));
+ fields.add(finishField(field));
return fields;
}
@@ -131,35 +138,35 @@ public class CsvReader implements Closeable {
int next = r.read();
if (next == -1) {
eof = true;
- fields.add(finalize(field));
+ fields.add(finishField(field));
return fields;
} else if (next == delimiter) {
- fields.add(finalize(field));
+ fields.add(finishField(field));
field = new StringBuilder();
} else if (next == '\r') {
int peek = r.read();
if (peek != '\n' && peek != -1)
r.unread();
- fields.add(finalize(field));
+ fields.add(finishField(field));
return fields;
} else if (next == '\n') {
- fields.add(finalize(field));
+ fields.add(finishField(field));
return fields;
} else {
// Lenient: treat extra content after
closing quote as unquoted
field.append((char) next);
}
} else if (c == delimiter) {
- fields.add(finalize(field));
+ fields.add(finishField(field));
field = new StringBuilder();
} else if (c == '\r') {
int next = r.read();
if (next != '\n' && next != -1)
r.unread();
- fields.add(finalize(field));
+ fields.add(finishField(field));
return fields;
} else if (c == '\n') {
- fields.add(finalize(field));
+ fields.add(finishField(field));
return fields;
} else {
field.append((char) c);
@@ -188,7 +195,7 @@ public class CsvReader implements Closeable {
int next = r.read();
if (next == quoteChar) {
// Doubled quote → literal quote
- field.append((char) quoteChar);
+ field.append(quoteChar);
} else {
// Closing quote; push back the
following character
if (next != -1)
@@ -201,7 +208,7 @@ public class CsvReader implements Closeable {
}
}
- private String finalize(StringBuilder sb) {
+ private String finishField(StringBuilder sb) {
var s = sb.toString();
return trimStrings ? s.trim() : s;
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvSerializer.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvSerializer.java
index 3b9ff7639c..88f4061a6f 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvSerializer.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvSerializer.java
@@ -37,30 +37,27 @@ import org.apache.juneau.serializer.*;
* becomes a row and bean properties become columns. The first row typically
contains column headers derived
* from bean property names.
*
- * <h5 class='section'>Data Structures Incompatible with CSV (vs. JSON):</h5>
- * <p>
- * CSV is a flat, tabular format. Unlike {@link
org.apache.juneau.json.JsonSerializer JSON}, which
- * supports nested objects, arrays, and type discriminators, CSV cannot
faithfully represent:
+ * <h5 class='section'>JSON Parity Features (opt-in):</h5>
+ * <ul>
+ * <li><b>Type discriminator</b> — {@link #addBeanTypes()
addBeanTypes()}.{@link #addRootType() addRootType()}
+ * adds a {@code _type} column for polymorphic parsing.
+ * <li><b>Byte arrays</b> — {@link #byteArrayFormat(ByteArrayFormat)
byteArrayFormat(BASE64)} (default) or
+ * {@code SEMICOLON_DELIMITED} for {@code byte[]}; primitive arrays as
{@code [1;2;3]}.
+ * <li><b>Nested structures</b> — {@link #allowNestedStructures(boolean)
allowNestedStructures(true)}
+ * enables inline {@code {key:val}} and {@code [val;val]} in cells.
+ * <li><b>Null marker</b> — {@link #nullValue(String)
nullValue("<NULL>")} (default) for unambiguous null.
+ * </ul>
+ *
+ * <h5 class='section'>Data Structures Not Supported:</h5>
* <ul>
- * <li><b>Raw primitive arrays</b> ({@code byte[]}, {@code int[]}, etc.) —
Serialize as
- * {@code Object.toString()} (e.g. {@code [B@12345}). <i>JSON</i>
supports arrays natively
- * and typically uses base64 for {@code byte[]}.
- * <li><b>Nested beans</b> — Flatten to a single row; structure is lost.
<i>JSON</i> preserves
- * nested objects naturally ({@code {"a":{"b":"c"}}}).
- * <li><b>Collections/arrays within beans</b> — {@code List<X>},
{@code Map<K,V>}, and
- * array properties serialize as {@code toString()}; they cannot be
parsed back. <i>JSON</i>
- * supports arrays and objects as first-class values.
- * <li><b>Generic type preservation</b> — No type discriminator. <i>JSON</i>
can add {@code @type}
- * via {@code addBeanTypes().addRootType()}.
* <li><b>Parent/inherited properties</b> — Hierarchy flattens; may collide
with child names.
- * <i>JSON</i> serializes all properties in a single object without loss.
- * <li><b>Optional wrappers</b> — May serialize as the inner value;
round-trip differs from
- * tree formats. <i>JSON</i> handles Optional consistently as value or
null.
+ * <li><b>Optional wrappers</b> — May serialize as the inner value;
round-trip differs from tree formats.
* </ul>
*
* <h5 class='section'>Best Supported:</h5>
* <p>
- * Collections of flat beans or maps whose properties are primitives, strings,
numbers, enums, or dates.
+ * Collections of flat beans or maps whose properties are primitives, strings,
numbers, enums, dates,
+ * byte arrays, or (with {@code allowNestedStructures}) nested beans, maps,
and lists.
*
* <h5 class='section'>Notes:</h5><ul>
* <li class='note'>This class is thread safe and reusable.
@@ -83,11 +80,17 @@ public class CsvSerializer extends WriterSerializer
implements CsvMetaProvider {
private static final Cache<HashKey,CsvSerializer> CACHE =
Cache.of(HashKey.class, CsvSerializer.class).build();
+ private ByteArrayFormat byteArrayFormat;
+ private boolean allowNestedStructures;
+ private String nullValue;
+
/**
* Constructor, default settings.
*/
protected Builder() {
produces("text/csv");
+ byteArrayFormat = ByteArrayFormat.BASE64;
+ nullValue = "<NULL>";
}
/**
@@ -98,6 +101,9 @@ public class CsvSerializer extends WriterSerializer
implements CsvMetaProvider {
*/
protected Builder(Builder copyFrom) {
super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ byteArrayFormat = copyFrom.byteArrayFormat;
+ allowNestedStructures = copyFrom.allowNestedStructures;
+ nullValue = copyFrom.nullValue;
}
/**
@@ -108,6 +114,34 @@ public class CsvSerializer extends WriterSerializer
implements CsvMetaProvider {
*/
protected Builder(CsvSerializer copyFrom) {
super(assertArgNotNull(ARG_copyFrom, copyFrom));
+ byteArrayFormat = copyFrom.byteArrayFormat;
+ allowNestedStructures = copyFrom.allowNestedStructures;
+ nullValue = copyFrom.nullValue;
+ }
+
+ /**
+ * String to write for null values. Parser treats cells
matching this as null.
+ *
+ * <p>
+ * Default is {@code <NULL>} to avoid confusion with the
literal string {@code "null"}.
+ *
+ * @param value The null marker string.
+ * @return This object.
+ */
+ public Builder nullValue(String value) {
+ nullValue = value;
+ return this;
+ }
+
+ /**
+ * Enables inline {@code {key:val}} and {@code [val;val]}
notation in cells for nested beans, maps, and lists.
+ *
+ * @param value Whether to allow nested structures.
+ * @return This object.
+ */
+ public Builder allowNestedStructures(boolean value) {
+ allowNestedStructures = value;
+ return this;
}
@Override /* Overridden from Builder */
@@ -140,6 +174,20 @@ public class CsvSerializer extends WriterSerializer
implements CsvMetaProvider {
return this;
}
+ /**
+ * Format for serializing {@code byte[]} arrays in CSV cells.
+ *
+ * <p>
+ * Default is {@link ByteArrayFormat#BASE64} (matches JSON).
+ *
+ * @param value The format to use.
+ * @return This object.
+ */
+ public Builder byteArrayFormat(ByteArrayFormat value) {
+ byteArrayFormat = value;
+ return this;
+ }
+
@Override /* Overridden from Builder */
public Builder annotations(Annotation...values) {
super.annotations(values);
@@ -308,6 +356,11 @@ public class CsvSerializer extends WriterSerializer
implements CsvMetaProvider {
return this;
}
+ @Override /* Overridden from Context.Builder */
+ public HashKey hashKey() {
+ return HashKey.of(super.hashKey(), byteArrayFormat,
allowNestedStructures, nullValue);
+ }
+
@Override /* Overridden from Context.Builder */
public CsvSerializer build() {
return cache(CACHE).build(CsvSerializer.class);
@@ -777,6 +830,9 @@ public class CsvSerializer extends WriterSerializer
implements CsvMetaProvider {
private final Map<ClassMeta<?>,CsvClassMeta> csvClassMetas = new
ConcurrentHashMap<>();
private final Map<BeanPropertyMeta,CsvBeanPropertyMeta>
csvBeanPropertyMetas = new ConcurrentHashMap<>();
+ private final ByteArrayFormat byteArrayFormat;
+ private final boolean allowNestedStructures;
+ private final String nullValue;
/**
* Constructor.
@@ -785,6 +841,36 @@ public class CsvSerializer extends WriterSerializer
implements CsvMetaProvider {
*/
public CsvSerializer(Builder builder) {
super(builder);
+ byteArrayFormat = builder.byteArrayFormat != null ?
builder.byteArrayFormat : ByteArrayFormat.BASE64;
+ allowNestedStructures = builder.allowNestedStructures;
+ nullValue = builder.nullValue != null ? builder.nullValue :
"<NULL>";
+ }
+
+ /**
+ * Returns the format for serializing {@code byte[]} arrays in CSV
cells.
+ *
+ * @return The byte array format.
+ */
+ public ByteArrayFormat getByteArrayFormat() {
+ return byteArrayFormat;
+ }
+
+ /**
+ * Returns whether inline {@code {key:val}} and {@code [val;val]}
notation is enabled.
+ *
+ * @return Whether nested structures are allowed.
+ */
+ public boolean isAllowNestedStructures() {
+ return allowNestedStructures;
+ }
+
+ /**
+ * Returns the string written for null values.
+ *
+ * @return The null marker.
+ */
+ public String getNullValue() {
+ return nullValue;
}
@Override /* Overridden from Context */
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvSerializerSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvSerializerSession.java
index a1d3238eb6..e99755255d 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvSerializerSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/csv/CsvSerializerSession.java
@@ -18,6 +18,7 @@ package org.apache.juneau.csv;
import static org.apache.juneau.commons.utils.AssertionUtils.*;
import static org.apache.juneau.commons.utils.CollectionUtils.*;
+import static org.apache.juneau.commons.utils.StringUtils.*;
import static org.apache.juneau.commons.utils.ThrowableUtils.*;
import static org.apache.juneau.commons.utils.Utils.*;
@@ -47,16 +48,33 @@ import org.apache.juneau.utils.*;
* </ul>
*
*/
+@SuppressWarnings({
+ "java:S110", // Inheritance depth acceptable for serializer session
hierarchy
+ "java:S115", // Constants use UPPER_snakeCase convention (e.g., ARG_ctx)
+ "java:S3776", // Cognitive complexity acceptable for doSerialize,
formatForCsvCell
+ "java:S6541", // Brain method acceptable for doSerialize,
formatForCsvCell
+})
public class CsvSerializerSession extends WriterSerializerSession {
// Argument name constants for assertArgNotNull
private static final String ARG_ctx = "ctx";
+ private final ByteArrayFormat byteArrayFormat;
+ private final boolean allowNestedStructures;
+ private final String nullValue;
+
/**
* Builder class.
*/
+ @SuppressWarnings({
+ "java:S110" // Inheritance depth acceptable for builder
hierarchy
+ })
public static class Builder extends WriterSerializerSession.Builder {
+ private ByteArrayFormat byteArrayFormat;
+ private boolean allowNestedStructures;
+ private String nullValue;
+
/**
* Constructor
*
@@ -65,6 +83,9 @@ public class CsvSerializerSession extends
WriterSerializerSession {
*/
protected Builder(CsvSerializer ctx) {
super(assertArgNotNull(ARG_ctx, ctx));
+ byteArrayFormat = ctx.getByteArrayFormat();
+ allowNestedStructures = ctx.isAllowNestedStructures();
+ nullValue = ctx.getNullValue();
}
@Override /* Overridden from Builder */
@@ -199,6 +220,9 @@ public class CsvSerializerSession extends
WriterSerializerSession {
*/
protected CsvSerializerSession(Builder builder) {
super(builder);
+ byteArrayFormat = builder.byteArrayFormat;
+ allowNestedStructures = builder.allowNestedStructures;
+ nullValue = builder.nullValue != null ? builder.nullValue :
"<NULL>";
}
/**
@@ -235,15 +259,22 @@ public class CsvSerializerSession extends
WriterSerializerSession {
@SuppressWarnings({
"rawtypes", // Raw types necessary for generic type handling
"unchecked", // Type erasure requires unchecked casts
+ "resource", // w is closed by try-with-resources; lambdas
capture it
})
@Override /* Overridden from SerializerSession */
protected void doSerialize(SerializerPipe pipe, Object o) throws
IOException, SerializeException {
-
- try (var w = getCsvWriter(pipe)) {
- var cm = getClassMetaForObject(o);
- Collection<?> l = null;
- if (cm.isArray()) {
- l = l((Object[])o);
+ var cm = push2("root", o, getClassMetaForObject(o));
+ if (cm == null)
+ return;
+ try {
+ try (var w = getCsvWriter(pipe)) {
+ Collection<?> l = null;
+ if (cm.isArray()) {
+ // Primitive arrays and byte[] serialize as
single row (value = [1;2;3] or base64)
+ if
(o.getClass().getComponentType().isPrimitive())
+ l = Collections.singletonList(o);
+ else
+ l = l((Object[])o);
} else if (cm.isCollection()) {
l = (Collection<?>)o;
} else if (cm.isStreamable()) {
@@ -262,78 +293,148 @@ public class CsvSerializerSession extends
WriterSerializerSession {
var firstRaw = firstOpt.get();
var firstEntry = applySwap(firstRaw,
getClassMetaForObject(firstRaw));
var entryType =
getClassMetaForObject(firstEntry);
+ // If swapped type is not a bean (e.g.
interface proxy), use raw type for strategy
+ if (! entryType.isBean() && firstRaw != null) {
+ var rawType =
getClassMetaForObject(firstRaw);
+ if (rawType.isBean())
+ entryType = rawType;
+ }
+
+ // Expected type for each element (for type
discriminator)
+ var eType = cm.isArray() || cm.isCollection()
|| cm.isStreamable()
+ ? cm.getElementType()
+ : getExpectedRootType(firstRaw);
+
+ // CSV is flat; when addBeanTypes or
addRootType is set, add _type column to all rows
+ var addTypeColumn = isAddBeanTypes() ||
isAddRootType();
+ var typeColName = addTypeColumn ?
getBeanTypePropertyName(entryType) : null;
// Determine the best representation strategy.
// Use the BeanMeta from the entry type for
bean serialization.
var bm = entryType.isBean() ?
entryType.getBeanMeta() : null;
if (bm != null) {
- // Bean or DynaBean path: header row =
property names
+ // Bean or DynaBean path: header row =
property names + optional _type
var addComma = Flag.create();
bm.getProperties().values().stream().filter(BeanPropertyMeta::canRead).forEach(x
-> {
addComma.ifSet(() ->
w.w(',')).set();
w.writeEntry(x.getName());
});
+ // Always append _type when
addBeanTypes or addRootType to support polymorphic parsing
+ if (addTypeColumn && ne(typeColName)) {
+ w.w(',');
+ w.writeEntry(typeColName);
+ }
w.append('\n');
var readableProps =
bm.getProperties().values().stream().filter(BeanPropertyMeta::canRead).toList();
l.forEach(x -> {
var addComma2 = Flag.create();
if (x == null) {
- // Null entry: write
null for each column
+ // Null entry: write
null marker for each column
readableProps.forEach(y
-> {
addComma2.ifSet(() -> w.w(',')).set();
-
w.writeEntry(null);
+
w.writeEntry(nullValue);
});
+ if (addTypeColumn &&
ne(typeColName)) {
+ w.w(',');
+
w.writeEntry("");
+ }
} else {
// Apply swap before
extracting bean properties (e.g. surrogate swaps)
var swapped =
applySwap(x, getClassMetaForObject(x));
- BeanMap<?> bean =
toBeanMap(swapped);
+ var aType =
getClassMetaForObject(swapped);
+ // When swap yields
non-bean (e.g. String), use raw object for property extraction
+ var objForBean =
aType.isBean() ? swapped : x;
+ BeanMap<?> bean =
toBeanMap(objForBean);
readableProps.forEach(y
-> {
addComma2.ifSet(() -> w.w(',')).set();
var value =
y.get(bean, y.getName());
value =
formatIfDateOrDuration(value);
+ value =
formatForCsvCell(value);
// Use
toString() to respect trimStrings setting on String values
if (value
instanceof String s) value = toString(s);
-
w.writeEntry(value);
+
w.writeEntry(value != null ? value : nullValue);
});
+ if (addTypeColumn &&
ne(typeColName)) {
+ var typeName =
getBeanTypeName(this, eType, aType, null);
+ w.w(',');
+
w.writeEntry(typeName != null ? typeName : "");
+ }
}
w.w('\n');
});
} else if (entryType.isMap()) {
- // Map path: header row = map keys from
the first entry
+ // Map path: header row = map keys from
the first entry + optional _type
var addComma = Flag.create();
var first = (Map) firstEntry;
first.keySet().forEach(x -> {
addComma.ifSet(() ->
w.w(',')).set();
// Apply trimStrings to map
keys as well
- w.writeEntry(x instanceof
String s ? toString(s) : x);
+ Object keyVal;
+ if (x == null)
+ keyVal = nullValue;
+ else if (x instanceof String s)
+ keyVal = toString(s);
+ else
+ keyVal = x;
+ w.writeEntry(keyVal);
});
+ if (addTypeColumn && ne(typeColName)) {
+ w.w(',');
+ w.writeEntry(typeColName);
+ }
w.append('\n');
l.forEach(x -> {
var addComma2 = Flag.create();
var swapped = applySwap(x,
getClassMetaForObject(x));
+ var aType =
getClassMetaForObject(swapped);
var map = (Map) swapped;
map.values().forEach(y -> {
addComma2.ifSet(() ->
w.w(',')).set();
var value =
applySwap(y, getClassMetaForObject(y));
+ value =
formatForCsvCell(value);
// Apply trimStrings to
map values
if (value instanceof
String s) value = toString(s);
- w.writeEntry(value);
+ w.writeEntry(value !=
null ? value : nullValue);
});
+ if (addTypeColumn &&
ne(typeColName)) {
+ var typeName =
getBeanTypeName(this, eType, aType, null);
+ w.w(',');
+ w.writeEntry(typeName
!= null ? typeName : "");
+ }
w.w('\n');
});
} else {
- // Simple value path: single "value"
column
+ // Simple value path: single "value"
column + optional _type
w.writeEntry("value");
+ if (addTypeColumn && ne(typeColName)) {
+ w.w(',');
+ w.writeEntry(typeColName);
+ }
w.append('\n');
l.forEach(x -> {
var value = applySwap(x,
getClassMetaForObject(x));
+ value = formatForCsvCell(value);
// Use toString() to respect
trimStrings setting
- w.writeEntry(value == null ?
null : toString(value));
+ w.writeEntry(value != null ?
toString(value) : nullValue);
+ if (addTypeColumn &&
ne(typeColName)) {
+ if (x != null) {
+ var aType =
getClassMetaForObject(x);
+ var typeName =
getBeanTypeName(this, eType, aType, null);
+ w.w(',');
+
w.writeEntry(typeName != null ? typeName : "");
+ } else {
+ w.w(',');
+
w.writeEntry("");
+ }
+ }
w.w('\n');
});
}
}
+ }
+ } finally {
+ pop();
}
}
@@ -349,6 +450,124 @@ public class CsvSerializerSession extends
WriterSerializerSession {
return value;
}
+ /**
+ * Formats a value for a CSV cell, handling byte[], primitive arrays,
and nested structures.
+ */
+ private Object formatForCsvCell(Object value) {
+ if (value == null)
+ return null;
+ if (allowNestedStructures) {
+ var type = getClassMetaForObject(value);
+ if (value instanceof Map || value instanceof Collection
|| value instanceof Object[]
+ || (type.isBean() && !(value instanceof
Map)))
+ return new CsvCellSerializer(byteArrayFormat,
nullValue).serialize(value, this);
+ }
+ if (value instanceof byte[] b) {
+ return byteArrayFormat ==
ByteArrayFormat.SEMICOLON_DELIMITED
+ ? formatByteArraySemicolon(b)
+ : base64Encode(b);
+ }
+ if (value instanceof int[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+ if (value instanceof long[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+ if (value instanceof double[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+ if (value instanceof float[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+ if (value instanceof short[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+ if (value instanceof boolean[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+ if (value instanceof char[] a) {
+ var sb = new StringBuilder();
+ sb.append('[');
+ for (var i = 0; i < a.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append((int) a[i]);
+ }
+ sb.append(']');
+ return sb.toString();
+ }
+ return value;
+ }
+
+ private static String formatByteArraySemicolon(byte[] b) {
+ var sb = new StringBuilder();
+ for (var i = 0; i < b.length; i++) {
+ if (i > 0) sb.append(';');
+ sb.append(b[i] & 0xff);
+ }
+ return sb.toString();
+ }
+
+ /**
+ * Prepares a value for inline cell serialization (bean→Map, date
format, etc.).
+ * Used by {@link CsvCellSerializer}.
+ */
+ Object prepareForInlineValue(Object value) {
+ if (value == null)
+ return null;
+ var swapped = applySwap(value, getClassMetaForObject(value));
+ var type = getClassMetaForObject(swapped);
+ if (type.isBean() && !(swapped instanceof Map))
+ return toBeanMap(swapped);
+ if (swapped instanceof Calendar || swapped instanceof Date ||
swapped instanceof Temporal
+ || swapped instanceof
javax.xml.datatype.XMLGregorianCalendar)
+ return Iso8601Utils.format(swapped, type,
getTimeZone());
+ if (swapped instanceof java.time.Duration)
+ return swapped.toString();
+ return swapped;
+ }
+
CsvWriter getCsvWriter(SerializerPipe out) {
var output = out.getRawOutput();
if (output instanceof CsvWriter output2)
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/html/HtmlStrippedDocSerializerSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/html/HtmlStrippedDocSerializerSession.java
index 0d4658e692..7c67570bba 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/html/HtmlStrippedDocSerializerSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/html/HtmlStrippedDocSerializerSession.java
@@ -189,6 +189,9 @@ public class HtmlStrippedDocSerializerSession extends
HtmlSerializerSession {
super(builder);
}
+ @SuppressWarnings({
+ "resource" // w is closed by try-with-resources; analyzer false
positive on super.doSerialize path
+ })
@Override /* Overridden from SerializerSession */
protected void doSerialize(SerializerPipe out, Object o) throws
IOException, SerializeException {
try (var w = getHtmlWriter(out)) {
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/oapi/OpenApiSerializerSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/oapi/OpenApiSerializerSession.java
index e8968985a4..8e81d7759f 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/oapi/OpenApiSerializerSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/oapi/OpenApiSerializerSession.java
@@ -25,7 +25,6 @@ import static org.apache.juneau.httppart.HttpPartFormat.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.charset.*;
-import java.time.temporal.*;
import java.util.*;
import java.util.function.*;
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/svl/ResolvingJsonMap.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/svl/ResolvingJsonMap.java
index d54eef6aaa..321895d305 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/svl/ResolvingJsonMap.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/svl/ResolvingJsonMap.java
@@ -57,7 +57,7 @@ public class ResolvingJsonMap extends JsonMap {
@Override /* Overridden from Object */
public boolean equals(Object o) {
- return this == o || (o instanceof ResolvingJsonMap other &&
super.equals(o));
+ return this == o || (o instanceof ResolvingJsonMap &&
super.equals(o));
}
@Override /* Overridden from Object */
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/xml/XmlSerializerSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/xml/XmlSerializerSession.java
index 0152a2cbf1..aefab5e43a 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/xml/XmlSerializerSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/xml/XmlSerializerSession.java
@@ -50,6 +50,11 @@ import org.apache.juneau.xml.annotation.*;
* </ul>
*/
+@SuppressWarnings({
+ "rawtypes", // Raw Map/Collection necessary for serializer dispatch
over heterogeneous types
+ "unchecked", // Type erasure requires unchecked casts in serializer
dispatch
+ "resource" // Writer/Closeable resources managed by
try-with-resources; analyzer FP in lambdas
+})
public class XmlSerializerSession extends WriterSerializerSession {
// Argument name constants for assertArgNotNull
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlParser.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlParser.java
index 89ba21c316..1c9a8119b8 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlParser.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlParser.java
@@ -17,15 +17,12 @@
package org.apache.juneau.yaml;
import static org.apache.juneau.commons.utils.AssertionUtils.*;
-import static org.apache.juneau.commons.utils.Utils.*;
import java.lang.annotation.*;
import java.nio.charset.*;
import java.util.*;
-import java.util.concurrent.*;
import org.apache.juneau.*;
-import org.apache.juneau.collections.*;
import org.apache.juneau.commons.collections.*;
import org.apache.juneau.commons.function.*;
import org.apache.juneau.commons.reflect.*;
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlParserSession.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlParserSession.java
index 10633d0133..b083a0ba9e 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlParserSession.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlParserSession.java
@@ -29,7 +29,6 @@ import java.util.function.*;
import org.apache.juneau.*;
import org.apache.juneau.collections.*;
-import org.apache.juneau.commons.lang.*;
import org.apache.juneau.commons.reflect.*;
import org.apache.juneau.commons.utils.*;
import org.apache.juneau.httppart.*;
@@ -46,6 +45,10 @@ import org.apache.juneau.utils.Iso8601Utils;
*/
@SuppressWarnings({
"java:S125", // State-machine and parse-path comments are
documentation, not commented-out code
+ "java:S135", // Multiple break/continue acceptable for YAML parsing
state machines
+ "java:S2589", // State checks in error path - analyzer FP on state
machine flow
+ "java:S2677", // r.read() return value intentionally ignored when
consuming/skipping chars
+ "java:S3626", // Redundant jump acceptable for state machine clarity
"unchecked",
"rawtypes",
"resource"
@@ -181,6 +184,7 @@ public class YamlParserSession extends ReaderParserSession {
return new Builder(ctx);
}
+ @SuppressWarnings("unused") // Stored for API consistency with
Builder.create(YamlParser)
private final YamlParser ctx;
/**
@@ -231,7 +235,8 @@ public class YamlParserSession extends ReaderParserSession {
}
@SuppressWarnings({
- "java:S3776" // Cognitive complexity acceptable for parser
dispatch
+ "java:S3776", // Cognitive complexity acceptable for parser
dispatch
+ "java:S6541" // Brain method acceptable for parser dispatch
})
private <T> T parseAnything(ClassMeta<?> eType, ParserReader r, Object
outer, BeanPropertyMeta pMeta) throws IOException, ParseException,
ExecutableException {
@@ -335,7 +340,7 @@ public class YamlParserSession extends ReaderParserSession {
o = convertToType(s, sType, eType, outer, pMeta);
} else if (c == '~') {
r.read(); // consume '~'
- o = null;
+ // o remains null (initialized at line 255)
} else {
String s = parsePlainScalar(r, 0);
o = handlePlainScalar(s, r, sType, eType, builder,
outer, pMeta, contentColumn > 0 ? contentColumn - 1 : 0);
@@ -350,9 +355,13 @@ public class YamlParserSession extends ReaderParserSession
{
return (T)o;
}
- @SuppressWarnings("rawtypes")
+ @SuppressWarnings({
+ "java:S107", // 8 parameters required for YAML scalar handling
dispatch
+ "java:S3776", // Cognitive complexity acceptable for scalar
type dispatch
+ "java:S6541" // Brain method acceptable for scalar handling
+ })
private <T> Object handleQuotedScalar(String s, ParserReader r,
ClassMeta<?> sType, ClassMeta<?> eType, BuilderSwap<T,Object> builder, Object
outer, BeanPropertyMeta pMeta, int keyIndent) throws IOException,
ParseException, ExecutableException {
- if (looksLikeMappingKey(s, r)) {
+ if (looksLikeMappingKey(r)) {
r.read(); // consume ':'
int cp = r.peek();
if (cp == ' ')
@@ -401,7 +410,11 @@ public class YamlParserSession extends ReaderParserSession
{
return convertToType(s, sType, eType, outer, pMeta);
}
- @SuppressWarnings("rawtypes")
+ @SuppressWarnings({
+ "java:S107", // 8 parameters required for YAML scalar handling
dispatch
+ "java:S3776", // Cognitive complexity acceptable for scalar
type dispatch
+ "java:S6541" // Brain method acceptable for scalar handling
+ })
private <T> Object handlePlainScalar(String s, ParserReader r,
ClassMeta<?> sType, ClassMeta<?> eType, BuilderSwap<T,Object> builder, Object
outer, BeanPropertyMeta pMeta, int keyIndent) throws IOException,
ParseException, ExecutableException {
if (s.isEmpty())
return null;
@@ -415,7 +428,7 @@ public class YamlParserSession extends ReaderParserSession {
return convertToType(s, sType, eType, outer, pMeta);
}
- if (looksLikeMappingKey(s, r)) {
+ if (looksLikeMappingKey(r)) {
r.read(); // consume ':'
int cp = r.peek();
if (cp == ' ')
@@ -463,6 +476,9 @@ public class YamlParserSession extends ReaderParserSession {
return convertToType(s, sType, eType, outer, pMeta);
}
+ @SuppressWarnings({
+ "java:S1172" // eType, pMeta kept for API consistency with
callers
+ })
private Object convertToType(String s, ClassMeta<?> sType, ClassMeta<?>
eType, Object outer, BeanPropertyMeta pMeta) throws ParseException {
if (sType.isObject()) {
return resolveScalarType(trim(s));
@@ -483,12 +499,8 @@ public class YamlParserSession extends ReaderParserSession
{
}
}
- private boolean looksLikeMappingKey(String s, ParserReader r) throws
IOException {
- int c = r.peek();
- if (c != ':')
- return false;
- // Peek past ':' to make sure it's followed by space, newline,
or EOF (not part of value)
- return true;
+ private static boolean looksLikeMappingKey(ParserReader r) throws
IOException {
+ return r.peek() == ':';
}
private <K,V> void parseBlockMappingRemainder(ParserReader r, Map<K,V>
m, ClassMeta<K> keyType, ClassMeta<V> valueType, BeanPropertyMeta pMeta, int
parentIndent) throws IOException, ParseException, ExecutableException {
@@ -518,7 +530,7 @@ public class YamlParserSession extends ReaderParserSession {
setCurrentProperty(null);
}
- private boolean isNullBlockValue(ParserReader r, int blockIndent)
throws IOException {
+ private static boolean isNullBlockValue(ParserReader r, int
blockIndent) throws IOException {
int c = r.peek();
if (c == -1)
return true;
@@ -540,7 +552,7 @@ public class YamlParserSession extends ReaderParserSession {
return nextColumn <= blockIndent;
}
- private int peekSecondChar(ParserReader r) throws IOException {
+ private static int peekSecondChar(ParserReader r) throws IOException {
r.read();
int c2 = r.peek();
r.unread();
@@ -554,7 +566,8 @@ public class YamlParserSession extends ReaderParserSession {
"java:S1168",
"java:S135",
"java:S2583",
- "java:S3776"
+ "java:S3776",
+ "java:S6541" // Brain method acceptable for flow mapping state
machine
})
private <K,V> Map<K,V> parseFlowMapping(ParserReader r, Map<K,V> m,
ClassMeta<K> keyType, ClassMeta<V> valueType, BeanPropertyMeta pMeta) throws
IOException, ParseException, ExecutableException {
@@ -596,16 +609,14 @@ public class YamlParserSession extends
ReaderParserSession {
else if (isWhitespace(c))
continue;
} else if (state == S4) {
- if (isWhitespace(c)) {
+ if (isWhitespace(c))
continue;
- } else {
- r.unread();
- K key = convertAttrToType(m, currKey,
keyType);
- V value = parseAnything(valueType, r,
m, pMeta);
- setName(valueType, value, key);
- m.put(key, value);
- state = S5;
- }
+ r.unread();
+ K key = convertAttrToType(m, currKey, keyType);
+ V value = parseAnything(valueType, r, m, pMeta);
+ setName(valueType, value, key);
+ m.put(key, value);
+ state = S5;
} else if (state == S5) {
if (c == ',') {
state = S6;
@@ -653,7 +664,7 @@ public class YamlParserSession extends ReaderParserSession {
return parsePlainFlowKey(r);
}
- private String parsePlainFlowKey(ParserReader r) throws IOException {
+ private static String parsePlainFlowKey(ParserReader r) throws
IOException {
var sb = new StringBuilder();
int c;
while ((c = r.read()) != -1) {
@@ -742,6 +753,9 @@ public class YamlParserSession extends ReaderParserSession {
// ==========================================
// Block mapping
// ==========================================
+ @SuppressWarnings({
+ "java:S3776" // Cognitive complexity acceptable for block
mapping parsing
+ })
private <K,V> Map<K,V> parseBlockMapping(ParserReader r, Map<K,V> m,
ClassMeta<K> keyType, ClassMeta<V> valueType, BeanPropertyMeta pMeta, int
parentIndent) throws IOException, ParseException, ExecutableException {
if (keyType == null)
@@ -793,7 +807,7 @@ public class YamlParserSession extends ReaderParserSession {
return m;
}
- private int skipBlanksAndCountIndent(ParserReader r) throws IOException
{
+ private static int skipBlanksAndCountIndent(ParserReader r) throws
IOException {
int indent = 0;
int c;
while ((c = r.read()) != -1) {
@@ -811,7 +825,7 @@ public class YamlParserSession extends ReaderParserSession {
return indent;
}
- private void unreadSpaces(ParserReader r, int count) throws IOException
{
+ private static void unreadSpaces(ParserReader r, int count) throws
IOException {
for (int i = 0; i < count; i++)
r.unread();
}
@@ -861,7 +875,8 @@ public class YamlParserSession extends ReaderParserSession {
@SuppressWarnings({
"java:S1168",
"java:S2583",
- "java:S3776"
+ "java:S3776",
+ "java:S6541" // Brain method acceptable for bean map flow state
machine
})
private <T> BeanMap<T> parseIntoBeanMapFlow(ParserReader r, BeanMap<T>
m) throws IOException, ParseException, ExecutableException {
@@ -902,31 +917,29 @@ public class YamlParserSession extends
ReaderParserSession {
else if (isWhitespace(c))
continue;
} else if (state == S4) {
- if (isWhitespace(c)) {
+ if (isWhitespace(c))
continue;
- } else {
- if (!
currAttr.equals(getBeanTypePropertyName(m.getClassMeta()))) {
- var pm =
m.getPropertyMeta(currAttr);
- setCurrentProperty(pm);
- if (pm == null) {
-
onUnknownProperty(currAttr, m, parseAnything(object(), r.unread(),
m.getBean(false), null));
- unmark();
- } else {
- unmark();
- var cm =
pm.getClassMeta();
- Object value =
parseAnything(cm, r.unread(), m.getBean(false), pm);
- setName(cm,
value, currAttr);
- try {
-
pm.set(m, currAttr, value);
- } catch
(BeanRuntimeException e) {
-
onBeanSetterException(pm, e);
- throw e;
- }
+ if (!
currAttr.equals(getBeanTypePropertyName(m.getClassMeta()))) {
+ var pm =
m.getPropertyMeta(currAttr);
+ setCurrentProperty(pm);
+ if (pm == null) {
+
onUnknownProperty(currAttr, m, parseAnything(object(), r.unread(),
m.getBean(false), null));
+ unmark();
+ } else {
+ unmark();
+ var cm =
pm.getClassMeta();
+ Object value =
parseAnything(cm, r.unread(), m.getBean(false), pm);
+ setName(cm, value,
currAttr);
+ try {
+ pm.set(m,
currAttr, value);
+ } catch
(BeanRuntimeException e) {
+
onBeanSetterException(pm, e);
+ throw e;
}
-
setCurrentProperty(null);
}
- state = S5;
+ setCurrentProperty(null);
}
+ state = S5;
} else if (state == S5) {
if (c == ',')
state = S2;
@@ -1034,6 +1047,9 @@ public class YamlParserSession extends
ReaderParserSession {
// ==========================================
// Block sequence: - value\n- value\n...
// ==========================================
+ @SuppressWarnings({
+ "java:S3776" // Cognitive complexity acceptable for block
sequence parsing
+ })
private <E> Collection<E> parseBlockSequence(ParserReader r,
Collection<E> l, ClassMeta<?> type, BeanPropertyMeta pMeta, int parentIndent)
throws IOException, ParseException, ExecutableException {
int blockIndent = -1;
@@ -1173,7 +1189,10 @@ public class YamlParserSession extends
ReaderParserSession {
// ==========================================
// Plain scalar (unquoted)
// ==========================================
- private String parsePlainScalar(ParserReader r, int indent) throws
IOException {
+ @SuppressWarnings({
+ "java:S3776" // Cognitive complexity acceptable for plain
scalar parsing
+ })
+ private static String parsePlainScalar(ParserReader r, int indent)
throws IOException {
var sb = new StringBuilder();
int c;
while ((c = r.read()) != -1) {
@@ -1196,10 +1215,7 @@ public class YamlParserSession extends
ReaderParserSession {
break;
}
sb.append((char)c);
- } else if (c == ',' || c == '}' || c == ']') {
- r.unread();
- break;
- } else if (c == '#' && sb.isEmpty()) {
+ } else if ((c == ',' || c == '}' || c == ']') || (c ==
'#' && sb.isEmpty())) {
r.unread();
break;
} else {
@@ -1218,7 +1234,8 @@ public class YamlParserSession extends
ReaderParserSession {
// Block scalar: | or >
// ==========================================
@SuppressWarnings({
- "java:S3776"
+ "java:S3776", // Cognitive complexity acceptable for block
scalar parsing
+ "java:S6541" // Brain method acceptable for block scalar state
machine
})
private String parseBlockScalar(ParserReader r, char indicator) throws
IOException, ParseException {
r.read(); // consume '|' or '>'
@@ -1354,7 +1371,7 @@ public class YamlParserSession extends
ReaderParserSession {
// Helper methods
// ==========================================
- private void skipWhitespaceAndComments(ParserReader r) throws
IOException {
+ private static void skipWhitespaceAndComments(ParserReader r) throws
IOException {
int c;
while ((c = r.read()) != -1) {
if (c == '#') {
@@ -1370,7 +1387,7 @@ public class YamlParserSession extends
ReaderParserSession {
}
}
- private void skipDocumentMarker(ParserReader r) throws IOException {
+ private static void skipDocumentMarker(ParserReader r) throws
IOException {
int c = r.peek();
if (c == '-') {
r.read();
@@ -1405,7 +1422,7 @@ public class YamlParserSession extends
ReaderParserSession {
}
}
- private void skipToEndOfLine(ParserReader r) throws IOException {
+ private static void skipToEndOfLine(ParserReader r) throws IOException {
int c;
while ((c = r.read()) != -1) {
if (c == '\n' || c == '\r')
@@ -1413,11 +1430,14 @@ public class YamlParserSession extends
ReaderParserSession {
}
}
- private boolean isWhitespace(int c) {
+ private static boolean isWhitespace(int c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r';
}
- private Object resolveScalarType(String s) {
+ @SuppressWarnings({
+ "java:S3776" // Cognitive complexity acceptable for scalar type
resolution
+ })
+ private static Object resolveScalarType(String s) {
if (s == null || "null".equals(s) || "~".equals(s) ||
s.isEmpty())
return null;
if ("true".equals(s) || "True".equals(s) || "TRUE".equals(s))
@@ -1425,32 +1445,38 @@ public class YamlParserSession extends
ReaderParserSession {
if ("false".equals(s) || "False".equals(s) || "FALSE".equals(s))
return Boolean.FALSE;
- // Try parsing as number
- if (!s.isEmpty()) {
- char first = s.charAt(0);
- if (first == '-' || first == '+' || (first >= '0' &&
first <= '9') || first == '.') {
- try {
- if (s.contains(".") || s.contains("e")
|| s.contains("E")) {
- Double d =
Double.parseDouble(s);
- if (!d.isInfinite() &&
!d.isNaN())
- return d;
- } else {
- try {
- return
Integer.parseInt(s);
- } catch
(@SuppressWarnings("unused") NumberFormatException e2) {
- try {
- return
Long.parseLong(s);
- } catch
(@SuppressWarnings("unused") NumberFormatException e3) {
- // Fall through
to string
- }
- }
- }
- } catch (@SuppressWarnings("unused")
NumberFormatException e) {
- // Fall through to string
- }
+ Object num = tryParseNumber(s);
+ if (num != null)
+ return num;
+ return s;
+ }
+
+ private static Object tryParseNumber(String s) {
+ if (s.isEmpty())
+ return null;
+ char first = s.charAt(0);
+ if (first != '-' && first != '+' && (first < '0' || first >
'9') && first != '.')
+ return null;
+ try {
+ if (s.contains(".") || s.contains("e") ||
s.contains("E")) {
+ Double d = Double.parseDouble(s);
+ return (!d.isInfinite() && !d.isNaN()) ? d :
null;
}
+ return tryParseIntegerOrLong(s);
+ } catch (@SuppressWarnings("unused") NumberFormatException e) {
+ return null;
}
+ }
- return s;
+ private static Object tryParseIntegerOrLong(String s) {
+ try {
+ return Integer.valueOf(s);
+ } catch (@SuppressWarnings("unused") NumberFormatException e) {
+ try {
+ return Long.valueOf(s);
+ } catch (@SuppressWarnings("unused")
NumberFormatException e2) {
+ return null;
+ }
+ }
}
}
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlSerializer.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlSerializer.java
index b041d1c68e..86acd77c0b 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlSerializer.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlSerializer.java
@@ -22,7 +22,6 @@ import static org.apache.juneau.commons.utils.Utils.*;
import java.lang.annotation.*;
import java.nio.charset.*;
import java.util.*;
-import java.util.concurrent.*;
import org.apache.juneau.*;
import org.apache.juneau.commons.collections.*;
diff --git
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlWriter.java
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlWriter.java
index b2e94a11c7..fd07a79bf2 100644
---
a/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlWriter.java
+++
b/juneau-core/juneau-marshall/src/main/java/org/apache/juneau/yaml/YamlWriter.java
@@ -192,7 +192,7 @@ public class YamlWriter extends SerializerWriter {
@SuppressWarnings({
"java:S3776" // Cognitive complexity acceptable for YAML
quoting checks
})
- private boolean needsQuoting(String s) {
+ private static boolean needsQuoting(String s) {
if (s.isEmpty())
return true;
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/a/rttests/RoundTripTest_Base.java
b/juneau-utest/src/test/java/org/apache/juneau/a/rttests/RoundTripTest_Base.java
index 87356de200..0d78f46277 100644
---
a/juneau-utest/src/test/java/org/apache/juneau/a/rttests/RoundTripTest_Base.java
+++
b/juneau-utest/src/test/java/org/apache/juneau/a/rttests/RoundTripTest_Base.java
@@ -131,14 +131,17 @@ public abstract class RoundTripTest_Base extends TestBase
{
* Returns true if the object can be serialized to CSV without error.
*
* <p>
- * CSV can serialize any non-null input, but restricts to non-null,
non-array inputs
- * to avoid serialization errors on raw byte arrays and similar types.
+ * CSV supports byte[], primitive arrays ([1;2;3]), nested structures
(when enabled),
+ * and type discriminators. Only 2D+ primitive arrays are excluded.
*/
protected static boolean isCsvSerializableInput(Object o) {
if (o == null) return false;
var cls = o.getClass();
- // Skip raw primitive arrays (byte[], char[], int[][], etc.) -
they serialize as toString()
- if (cls.isArray() && cls.getComponentType().isPrimitive())
return false;
+ // Skip 2D+ primitive arrays (int[][], etc.) - not supported
+ if (cls.isArray()) {
+ var ct = cls.getComponentType();
+ if (ct.isArray() &&
ct.getComponentType().isPrimitive()) return false;
+ }
return true;
}
@@ -146,17 +149,13 @@ public abstract class RoundTripTest_Base extends TestBase
{
* Returns true if the object can be faithfully round-tripped through
CSV.
*
* <p>
- * CSV is tabular and only round-trips cleanly when:
- * <ul>
- * <li>The object is a non-empty {@link Collection} of flat beans or
Maps.
- * <li>Primitive arrays, 2D arrays, scalar lists, and enum arrays are
excluded
- * because CSV cannot unambiguously represent them during parsing.
- * </ul>
+ * CSV round-trips when the object is a non-empty {@link Collection} of
flat beans or Maps
+ * whose properties are primitives, strings, numbers, dates, byte
arrays, or primitive arrays.
+ * Nested structures require {@code allowNestedStructures(true)}.
*/
protected static boolean isCsvRoundTripCompatible(Object o) {
if (o == null)
return false;
- // Only Collections are supported; reject raw arrays of any kind
if (!(o instanceof Collection<?> col))
return false;
if (col.isEmpty())
@@ -170,18 +169,19 @@ public abstract class RoundTripTest_Base extends TestBase
{
private static boolean isCsvCompatibleElement(Object elem) {
if (elem == null) return false;
var cls = elem.getClass();
- // Reject scalars, arrays, enums, Optional, and any type that
isn't a bean or Map
- if (cls.isPrimitive() || cls.isArray()) return false;
+ if (cls.isPrimitive()) return false;
if (elem instanceof Number || elem instanceof Boolean || elem
instanceof Character) return false;
if (elem instanceof CharSequence || cls.isEnum()) return false;
if (elem instanceof java.util.Optional || elem instanceof
Collection) return false;
- // Accept Maps only if all values are also simple types
+ // 1D primitive arrays and byte[] are supported
+ if (cls.isArray()) {
+ var ct = cls.getComponentType();
+ return !ct.isArray(); // 1D arrays only
+ }
if (elem instanceof Map m) {
return m.values().stream().allMatch(v -> v == null ||
isCsvSimpleType(v.getClass()));
}
- // Reject JDK types that are not beans
if (cls.getName().startsWith("java.") ||
cls.getName().startsWith("javax.")) return false;
- // For POJO beans: only accept if all public fields have simple
(flat) types
for (var field : cls.getFields()) {
if (!isCsvSimpleType(field.getType())) return false;
}
@@ -190,7 +190,7 @@ public abstract class RoundTripTest_Base extends TestBase {
private static boolean isCsvSimpleType(Class<?> t) {
if (t == null) return true;
- return t.isPrimitive()
+ if (t.isPrimitive()
|| t == String.class
|| t == Boolean.class
|| t == Character.class
@@ -198,6 +198,13 @@ public abstract class RoundTripTest_Base extends TestBase {
|| t.isEnum()
|| java.time.temporal.Temporal.class.isAssignableFrom(t)
|| t == java.util.Date.class
- || t == java.util.Calendar.class;
+ || t == java.util.Calendar.class)
+ return true;
+ // byte[] and primitive arrays [1;2;3]
+ if (t.isArray()) {
+ var ct = t.getComponentType();
+ return ct.isPrimitive() || ct == Byte.class;
+ }
+ return false;
}
}
\ No newline at end of file
diff --git
a/juneau-utest/src/test/java/org/apache/juneau/csv/CsvParser_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/csv/CsvParser_Test.java
index b41ebc320b..af3ee4f310 100644
--- a/juneau-utest/src/test/java/org/apache/juneau/csv/CsvParser_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/csv/CsvParser_Test.java
@@ -120,7 +120,7 @@ class CsvParser_Test extends TestBase {
//====================================================================================================
@Test void d01_parseNullValues() throws Exception {
- var csv = "b,c\nnull,1\nb2,null\n";
+ var csv = "b,c\n<NULL>,1\nb2,<NULL>\n";
var r = parseList(csv, B.class);
assertEquals(2, r.size());
assertNull(r.get(0).b);
diff --git a/juneau-utest/src/test/java/org/apache/juneau/csv/Csv_Test.java
b/juneau-utest/src/test/java/org/apache/juneau/csv/Csv_Test.java
index b50007f450..f681a75bd8 100755
--- a/juneau-utest/src/test/java/org/apache/juneau/csv/Csv_Test.java
+++ b/juneau-utest/src/test/java/org/apache/juneau/csv/Csv_Test.java
@@ -145,11 +145,11 @@ class Csv_Test extends TestBase {
var s = CsvSerializer.create().swaps(DateSwap.class).build();
var r = s.serialize(l);
- // Should have users and null values
+ // Should have users and null values (null marker defaults to
<NULL>)
assertTrue(r.contains("user1"));
assertTrue(r.contains("user2"));
assertTrue(r.contains("user3"));
- assertTrue(r.contains("null"));
+ assertTrue(r.contains("<NULL>"));
assertTrue(r.contains("1970-01-01") ||
r.contains("1969-12-31"), "Should have formatted date but was: " + r);
}
@@ -320,15 +320,14 @@ class Csv_Test extends TestBase {
l.add(new G(null, "null"));
var r = CsvSerializer.DEFAULT.serialize(l);
- // Java null → unquoted "null"; the String "null" → quoted
"\"null\""
- // Both serialize as: null,"null"
- assertTrue(r.contains("null,\"null\"") ||
r.contains("null,null"),
- "Unexpected output: " + r);
+ // Java null → null marker (<NULL> by default); the String
"null" → quoted "\"null\""
+ assertTrue(r.contains("<NULL>") && r.contains("null"),
"Unexpected output: " + r);
}
public static class G {
public String a;
public String b;
+ public G() {}
public G(String a, String b) { this.a = a; this.b = b; }
}
@@ -348,4 +347,227 @@ class Csv_Test extends TestBase {
var r = CsvSerializer.DEFAULT.serialize(new F("hello", 42));
assertEquals("b,c\nhello,42\n", r);
}
+
+
//====================================================================================================
+ // Test type discriminator - addBeanTypes adds _type column
+
//====================================================================================================
+
//====================================================================================================
+ // Test type discriminator - parser skips _type column when target is
concrete
+
//====================================================================================================
+ @Test void j01_typeDiscriminator_skipsTypeColumn() throws Exception {
+ // CSV with _type column - parser skips it for concrete Circle
type
+ var csv = "name,radius,_type\nc1,10,Circle\nc2,20,Circle\n";
+ var p = CsvParser.create().build();
+
+ @SuppressWarnings("unchecked")
+ var parsed = (List<Circle>) p.parse(csv, List.class,
Circle.class);
+
+ assertNotNull(parsed);
+ assertEquals(2, parsed.size());
+ assertEquals("c1", parsed.get(0).name);
+ assertEquals(10, parsed.get(0).radius);
+ assertEquals("c2", parsed.get(1).name);
+ assertEquals(20, parsed.get(1).radius);
+ }
+
+
//====================================================================================================
+ // Test type discriminator - single bean with _type column
+
//====================================================================================================
+ @Test void j02_typeDiscriminator_singleBean() throws Exception {
+ var csv = "name,radius,_type\nc1,10,Circle\n";
+ var p = CsvParser.create().build();
+
+ var parsed = p.parse(csv, Circle.class);
+ assertNotNull(parsed);
+ assertEquals("c1", parsed.name);
+ assertEquals(10, parsed.radius);
+ }
+
+ @org.apache.juneau.annotation.Bean(dictionary = {Circle.class,
Rectangle.class})
+ public interface Shape {
+ String getName();
+ }
+
+ @org.apache.juneau.annotation.Bean(typeName = "Circle")
+ public static class Circle implements Shape {
+ public String name;
+ public int radius;
+
+ public Circle() {}
+ public Circle(String name, int radius) {
+ this.name = name;
+ this.radius = radius;
+ }
+ @Override
+ public String getName() { return name; }
+ }
+
+ @org.apache.juneau.annotation.Bean(typeName = "Rectangle")
+ public static class Rectangle implements Shape {
+ public String name;
+ public int width;
+ public int height;
+
+ public Rectangle() {}
+ public Rectangle(String name, int width, int height) {
+ this.name = name;
+ this.width = width;
+ this.height = height;
+ }
+ @Override
+ public String getName() { return name; }
+ }
+
+
//====================================================================================================
+ // Test byte[] BASE64 round-trip
+
//====================================================================================================
+ @Test void k01_byteArray_base64() throws Exception {
+ var bytes = "Hello
World".getBytes(java.nio.charset.StandardCharsets.UTF_8);
+ var l = new LinkedList<>();
+ l.add(new I("row1", bytes));
+ l.add(new I("row2", new byte[]{1, 2, 3}));
+
+ var s =
CsvSerializer.create().byteArrayFormat(ByteArrayFormat.BASE64).build();
+ var p =
CsvParser.create().byteArrayFormat(ByteArrayFormat.BASE64).build();
+
+ var csv = s.serialize(l);
+ assertTrue(csv.contains("SGVsbG8gV29ybGQ=") ||
csv.contains("data"), "Should have base64: " + csv);
+
+ @SuppressWarnings("unchecked")
+ var parsed = (List<I>) p.parse(csv, List.class, I.class);
+ assertNotNull(parsed);
+ assertEquals(2, parsed.size());
+ assertArrayEquals(bytes, parsed.get(0).data);
+ assertArrayEquals(new byte[]{1, 2, 3}, parsed.get(1).data);
+ }
+
+
//====================================================================================================
+ // Test byte[] SEMICOLON_DELIMITED round-trip
+
//====================================================================================================
+ @Test void k02_byteArray_semicolonDelimited() throws Exception {
+ var bytes = new byte[]{72, 101, 108, 108, 111};
+ var l = new LinkedList<>();
+ l.add(new I("row1", bytes));
+
+ var s =
CsvSerializer.create().byteArrayFormat(ByteArrayFormat.SEMICOLON_DELIMITED).build();
+ var p =
CsvParser.create().byteArrayFormat(ByteArrayFormat.SEMICOLON_DELIMITED).build();
+
+ var csv = s.serialize(l);
+ assertTrue(csv.contains("72;101;108;108;111"), "Should have
semicolon format: " + csv);
+
+ @SuppressWarnings("unchecked")
+ var parsed = (List<I>) p.parse(csv, List.class, I.class);
+ assertNotNull(parsed);
+ assertArrayEquals(bytes, parsed.get(0).data);
+ }
+
+ public static class I {
+ public String name;
+ public byte[] data;
+
+ public I() {}
+ public I(String name, byte[] data) {
+ this.name = name;
+ this.data = data;
+ }
+ }
+
+
//====================================================================================================
+ // Test int[] and double[] via [1;2;3] format
+
//====================================================================================================
+ @Test void k03_primitiveArrays() throws Exception {
+ var l = new LinkedList<>();
+ l.add(new H("row1", new int[]{1, 2, 3}, new double[]{1.5, 2.5,
3.5}));
+ l.add(new H("row2", new int[]{}, new double[]{}));
+
+ var s = CsvSerializer.DEFAULT;
+ var p = CsvParser.DEFAULT;
+
+ var csv = s.serialize(l);
+ assertTrue(csv.contains("[1;2;3]"), "Should have int array
format: " + csv);
+ assertTrue(csv.contains("[1.5;2.5;3.5]"), "Should have double
array format: " + csv);
+
+ @SuppressWarnings("unchecked")
+ var parsed = (List<H>) p.parse(csv, List.class, H.class);
+ assertNotNull(parsed);
+ assertEquals(2, parsed.size());
+ assertArrayEquals(new int[]{1, 2, 3}, parsed.get(0).ints);
+ assertArrayEquals(new double[]{1.5, 2.5, 3.5},
parsed.get(0).doubles);
+ assertArrayEquals(new int[0], parsed.get(1).ints);
+ assertArrayEquals(new double[0], parsed.get(1).doubles);
+ }
+
+ public static class H {
+ public String name;
+ public int[] ints;
+ public double[] doubles;
+
+ public H() {}
+ public H(String name, int[] ints, double[] doubles) {
+ this.name = name;
+ this.ints = ints;
+ this.doubles = doubles;
+ }
+ }
+
+
//====================================================================================================
+ // Test nested structures - allowNestedStructures with {key:val} and
[val;val]
+
//====================================================================================================
+ @Test void l01_nestedStructures() throws Exception {
+ var l = new LinkedList<>();
+ l.add(new J("row1", List.of("a", "b", "c"), Map.of("x", 1, "y",
2)));
+ l.add(new J("row2", List.of(), Map.of()));
+
+ var s =
CsvSerializer.create().allowNestedStructures(true).build();
+ var p = CsvParser.create().allowNestedStructures(true).build();
+
+ var csv = s.serialize(l);
+ assertTrue(csv.contains("[a;b;c]") || csv.contains("tags"),
"Should have array notation: " + csv);
+ assertTrue(csv.contains("x:1") || csv.contains("y:2") ||
csv.contains("meta"), "Should have object notation: " + csv);
+
+ @SuppressWarnings("unchecked")
+ var parsed = (List<J>) p.parse(csv, List.class, J.class);
+ assertNotNull(parsed);
+ assertEquals(2, parsed.size());
+ assertEquals(List.of("a", "b", "c"), parsed.get(0).tags);
+ assertEquals(2, parsed.get(0).meta.size());
+ assertEquals(1, ((Number)
parsed.get(0).meta.get("x")).intValue());
+ assertEquals(2, ((Number)
parsed.get(0).meta.get("y")).intValue());
+ assertEquals(List.of(), parsed.get(1).tags);
+ assertTrue(parsed.get(1).meta.isEmpty());
+ }
+
+ public static class J {
+ public String name;
+ public List<String> tags;
+ public Map<String, Object> meta;
+
+ public J() {}
+ public J(String name, List<String> tags, Map<String, Object>
meta) {
+ this.name = name;
+ this.tags = tags;
+ this.meta = meta;
+ }
+ }
+
+
//====================================================================================================
+ // Test nullValue() - custom null marker
+
//====================================================================================================
+ @Test void m01_nullValue() throws Exception {
+ var l = new LinkedList<>();
+ l.add(new G(null, "x"));
+
+ var s = CsvSerializer.create().nullValue("<NULL>").build();
+ var p = CsvParser.create().nullValue("<NULL>").build();
+
+ var csv = s.serialize(l);
+ assertTrue(csv.contains("<NULL>"), "Should have null marker: "
+ csv);
+
+ @SuppressWarnings("unchecked")
+ var parsed = (List<G>) p.parse(csv, List.class, G.class);
+ assertNotNull(parsed);
+ assertEquals(1, parsed.size());
+ assertNull(parsed.get(0).a);
+ assertEquals("x", parsed.get(0).b);
+ }
}
\ No newline at end of file