You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tapestry.apache.org by th...@apache.org on 2026/10/17 13:34:35 UTC
(tapestry-5) 01/02: TAP5-2837: Improve JSON serialization performance (#67)
This is an automated email from the ASF dual-hosted git repository.
thiagohp pushed a commit to branch 5.9.x
in repository https://gitbox.apache.org/repos/asf/tapestry-5.git
commit 1175970e98e1cf47580ae2a27278f85022c21523
Author: Ben Weidig <be...@users.noreply.github.com>
AuthorDate: Sat Oct 10 12:31:27 2026 +0200
TAP5-2837: Improve JSON serialization performance (#67)
---
.../org/apache/tapestry5/json/CompactSession.java | 23 +-
.../org/apache/tapestry5/json/JSONCollection.java | 36 +-
.../java/org/apache/tapestry5/json/JSONObject.java | 200 ++++-
.../org/apache/tapestry5/json/JSONPrintSink.java | 92 +++
.../org/apache/tapestry5/json/JSONStringer.java | 3 +
.../org/apache/tapestry5/json/JSONTokener.java | 4 +-
.../apache/tapestry5/json/PrettyPrintSession.java | 41 +-
.../org/apache/tapestry5/json/package-info.java | 4 +-
.../org/apache/tapestry5/json/JSONObjectTest.java | 896 +++++++++++++++++++++
9 files changed, 1241 insertions(+), 58 deletions(-)
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
index 8eced6925..55127a23a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/CompactSession.java
@@ -1,4 +1,4 @@
-// Copyright 2010 The Apache Software Foundation
+// Copyright 2010, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -18,16 +18,21 @@ import java.io.PrintWriter;
/**
* Prints the JSON content compactly, with no indentation or extra whitespace.
- *
+ *
* @since 5.2.0
*/
class CompactSession implements JSONPrintSession
{
- private final PrintWriter writer;
+ private final JSONPrintSink out;
+
+ CompactSession(StringBuilder out)
+ {
+ this.out = new StringBuilderPrintSink(out);
+ }
public CompactSession(PrintWriter writer)
{
- this.writer = writer;
+ this.out = new WriterPrintSink(writer);
}
@Override
@@ -51,7 +56,7 @@ class CompactSession implements JSONPrintSession
@Override
public JSONPrintSession print(String value)
{
- writer.print(value);
+ out.append(value);
return this;
}
@@ -59,13 +64,17 @@ class CompactSession implements JSONPrintSession
@Override
public JSONPrintSession printQuoted(String value)
{
- return print(JSONObject.quote(value));
+ out.append('"');
+ JSONObject.escapeInto(out, value);
+ out.append('"');
+
+ return this;
}
@Override
public JSONPrintSession printSymbol(char symbol)
{
- writer.print(symbol);
+ out.append(symbol);
return this;
}
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
index c5ee1082c..0615bc47a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONCollection.java
@@ -12,7 +12,6 @@
package org.apache.tapestry5.json;
-import java.io.CharArrayWriter;
import java.io.PrintWriter;
import java.io.Serializable;
@@ -38,16 +37,13 @@ public abstract class JSONCollection implements Serializable
@Override
public String toString()
{
- CharArrayWriter caw = new CharArrayWriter();
- PrintWriter pw = new PrintWriter(caw);
+ StringBuilder out = new StringBuilder(estimateCapacity());
- JSONPrintSession session = new PrettyPrintSession(pw);
+ JSONPrintSession session = new PrettyPrintSession(out);
print(session);
- pw.close();
-
- return caw.toString();
+ return out.toString();
}
/**
@@ -68,16 +64,32 @@ public abstract class JSONCollection implements Serializable
*/
public String toCompactString()
{
- CharArrayWriter caw = new CharArrayWriter();
- PrintWriter pw = new PrintWriter(caw);
+ StringBuilder out = new StringBuilder(estimateCapacity());
- print(pw);
+ JSONPrintSession session = new CompactSession(out);
- pw.close();
+ print(session);
- return caw.toString();
+ return out.toString();
+ }
+
+ /**
+ * A cheap starting capacity for the {@link StringBuilder} backing {@link #toString()} /
+ * {@link #toCompactString()}, to avoid repeated grow-and-copy cycles for non-trivial trees.
+ * Scales with the top-level collection's element count only; recursing into nested
+ * collections isn't worth the extra bookkeeping for a rough estimate.
+ */
+ private int estimateCapacity()
+ {
+ return Math.max(256, size() * 32);
}
+ /**
+ * The number of top-level entries (name/value pairs for {@link JSONObject}, elements for
+ * {@link JSONArray}), used only to estimate a starting buffer capacity.
+ */
+ abstract int size();
+
/**
* Prints the JSONObject to the write (compactly or not).
*
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
index 321085ed5..b7f034549 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONObject.java
@@ -775,14 +775,154 @@ public final class JSONObject extends JSONCollection implements Map<String, Obje
if (data == null) {
return "\"\"";
}
- try {
- JSONStringer stringer = new JSONStringer();
- stringer.open(JSONStringer.Scope.NULL, "");
- stringer.string(data);
- stringer.close(JSONStringer.Scope.NULL, JSONStringer.Scope.NULL, "");
- return stringer.toString();
- } catch (RuntimeException e) {
- throw new AssertionError();
+
+ StringBuilder out = new StringBuilder(data.length() + 8);
+ out.append('"');
+ escapeInto(out, data);
+ out.append('"');
+ return out.toString();
+ }
+
+ private static final char[] HEX_DIGITS = "0123456789abcdef".toCharArray();
+
+ /**
+ * Appends the escaped (but not quoted) form of {@code value} to {@code out}: the same
+ * character escaping rules as {@link #quote(String)}, without allocating an intermediate
+ * String or wrapping quotes.
+ *
+ * <p>This is the direct-to-{@link StringBuilder} fast path used by {@code quote()} itself,
+ * which always targets a StringBuilder and is called often enough (once per printed string/key)
+ * that the {@link JSONPrintSink} indirection used by {@link #escapeInto(JSONPrintSink, String)}
+ * measurably costs more than it's worth here. See {@link #escapeInto(JSONPrintSink, String)}
+ * for the print-session variant.
+ */
+ static void escapeInto(StringBuilder out, String value) {
+ char currentChar = 0;
+
+ for (int i = 0, length = value.length(); i < length; i++) {
+ char previousChar = currentChar;
+ currentChar = value.charAt(i);
+
+ /*
+ * From RFC 4627, "All Unicode characters may be placed within the
+ * quotation marks except for the characters that must be escaped:
+ * quotation mark, reverse solidus, and the control characters
+ * (U+0000 through U+001F)."
+ */
+ switch (currentChar) {
+ case '"':
+ case '\\':
+ out.append('\\').append(currentChar);
+ break;
+
+ case '/':
+ // it makes life easier for HTML embedding of javascript if we escape </ sequences
+ if (previousChar == '<') {
+ out.append('\\');
+ }
+ out.append(currentChar);
+ break;
+
+ case '\t':
+ out.append("\\t");
+ break;
+
+ case '\b':
+ out.append("\\b");
+ break;
+
+ case '\n':
+ out.append("\\n");
+ break;
+
+ case '\r':
+ out.append("\\r");
+ break;
+
+ case '\f':
+ out.append("\\f");
+ break;
+
+ default:
+ if (currentChar <= 0x1F || (currentChar >= 0x0080 && currentChar < 0x00a0) || (currentChar >= 0x2000 && currentChar < 0x2100)) {
+ out.append('\\').append('u')
+ .append(HEX_DIGITS[(currentChar >> 12) & 0xF])
+ .append(HEX_DIGITS[(currentChar >> 8) & 0xF])
+ .append(HEX_DIGITS[(currentChar >> 4) & 0xF])
+ .append(HEX_DIGITS[currentChar & 0xF]);
+ } else {
+ out.append(currentChar);
+ }
+ break;
+ }
+ }
+ }
+
+ /**
+ * Same escaping rules as {@link #escapeInto(StringBuilder, String)}, targeting a
+ * {@link JSONPrintSink} instead.
+ *
+ * <p>Used by the print sessions, whose destination varies between an in-memory
+ * {@link StringBuilder} ({@code toString()}/{@code toCompactString()}) and a
+ * caller-supplied {@link java.io.PrintWriter} ({@code print(PrintWriter, boolean)}),
+ * so they can escape directly into whichever one they were constructed with.
+ *
+ * <p>Note: Having dedicated methods for the types instead of a classifying mechanism
+ * keeps the call site monomorphic for the JVM, allowing for greater optimization
+ * potential.
+ */
+ static void escapeInto(JSONPrintSink out, String value) {
+ char currentChar = 0;
+
+ for (int i = 0, length = value.length(); i < length; i++) {
+ char previousChar = currentChar;
+ currentChar = value.charAt(i);
+
+ switch (currentChar) {
+ case '"':
+ case '\\':
+ out.append('\\').append(currentChar);
+ break;
+
+ case '/':
+ if (previousChar == '<') {
+ out.append('\\');
+ }
+ out.append(currentChar);
+ break;
+
+ case '\t':
+ out.append("\\t");
+ break;
+
+ case '\b':
+ out.append("\\b");
+ break;
+
+ case '\n':
+ out.append("\\n");
+ break;
+
+ case '\r':
+ out.append("\\r");
+ break;
+
+ case '\f':
+ out.append("\\f");
+ break;
+
+ default:
+ if (currentChar <= 0x1F || (currentChar >= 0x0080 && currentChar < 0x00a0) || (currentChar >= 0x2000 && currentChar < 0x2100)) {
+ out.append('\\').append('u')
+ .append(HEX_DIGITS[(currentChar >> 12) & 0xF])
+ .append(HEX_DIGITS[(currentChar >> 8) & 0xF])
+ .append(HEX_DIGITS[(currentChar >> 4) & 0xF])
+ .append(HEX_DIGITS[currentChar & 0xF]);
+ } else {
+ out.append(currentChar);
+ }
+ break;
+ }
}
}
@@ -826,7 +966,6 @@ public final class JSONObject extends JSONCollection implements Map<String, Obje
session.printSymbol('}');
}
-
/**
* Prints a value (a JSONArray or JSONObject, or a value stored in an array or object) using
* the session.
@@ -835,48 +974,61 @@ public final class JSONObject extends JSONCollection implements Map<String, Obje
*/
static void printValue(JSONPrintSession session, Object value)
{
+ // Orderd by (assumed) most common frequency in JSON
+ // Null Check: Must be first.
if (value == null || value == NULL)
{
session.print("null");
return;
}
- if (value instanceof JSONObject)
- {
- ((JSONObject) value).print(session);
- return;
- }
- if (value instanceof JSONArray)
+ // String: Explicitly checking here prevents the most common
+ // JSON type from falling through all other checks.
+ if (value instanceof String)
{
- ((JSONArray) value).print(session);
+ session.printQuoted((String) value);
return;
}
- if (value instanceof JSONString)
+ // Number: Highly common in JSON payloads.
+ if (value instanceof Number)
{
- String printValue = ((JSONString) value).toJSONString();
-
+ String printValue = numberToString((Number) value);
session.print(printValue);
-
return;
}
- if (value instanceof Number)
+ // JSONObject: The primary structural container.
+ if (value instanceof JSONObject)
{
- String printValue = numberToString((Number) value);
- session.print(printValue);
+ ((JSONObject) value).print(session);
return;
}
+ // Boolean: Common, but usually fewer instances per payload than numbers.
if (value instanceof Boolean)
{
session.print(value.toString());
+ return;
+ }
+ // JSONArray: Generally fewer arrays than objects in nested structures.
+ if (value instanceof JSONArray)
+ {
+ ((JSONArray) value).print(session);
+ return;
+ }
+
+ // JSONString: Kept near the bottom assuming it is a custom wrapper.
+ if (value instanceof JSONString)
+ {
+ String printValue = ((JSONString) value).toJSONString();
+ session.print(printValue);
return;
}
- // Otherwise it really should just be a string. Nothing else can go in.
+ // Fallback for any other unexpected objects
session.printQuoted(value.toString());
}
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java
new file mode 100644
index 000000000..ccd843af9
--- /dev/null
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONPrintSink.java
@@ -0,0 +1,92 @@
+// Copyright 2026 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.json;
+
+import java.io.PrintWriter;
+
+/**
+ * The destination a {@link JSONPrintSession} (and {@link JSONObject#escapeInto}) writes
+ * characters to: either an in-memory buffer (for {@code toString()}/{@code toCompactString()})
+ * or a caller-supplied {@link PrintWriter} (for {@code print(PrintWriter, boolean)}).
+ *
+ * <p>Deliberately narrower than {@link Appendable}: neither implementation below can actually throw
+ * {@link java.io.IOException}, so this avoids forcing checked-exception handling onto every
+ * append call in the hot print path.
+ */
+interface JSONPrintSink
+{
+ JSONPrintSink append(String value);
+
+ JSONPrintSink append(char value);
+}
+
+/**
+ * Sink backed by an in-memory {@link StringBuilder}, used to build a {@code String} directly
+ * without an intermediate {@code CharArrayWriter}/{@code PrintWriter} pair.
+ */
+class StringBuilderPrintSink implements JSONPrintSink
+{
+ private final StringBuilder out;
+
+ StringBuilderPrintSink(StringBuilder out)
+ {
+ this.out = out;
+ }
+
+ @Override
+ public JSONPrintSink append(String value)
+ {
+ out.append(value);
+
+ return this;
+ }
+
+ @Override
+ public JSONPrintSink append(char value)
+ {
+ out.append(value);
+
+ return this;
+ }
+}
+
+/**
+ * Sink backed by a caller-supplied {@link PrintWriter}, used by {@code print(PrintWriter, boolean)}.
+ */
+class WriterPrintSink implements JSONPrintSink
+{
+ private final PrintWriter writer;
+
+ WriterPrintSink(PrintWriter writer)
+ {
+ this.writer = writer;
+ }
+
+ @Override
+ public JSONPrintSink append(String value)
+ {
+ writer.print(value);
+
+ return this;
+ }
+
+ @Override
+ public JSONPrintSink append(char value)
+ {
+ writer.write(value);
+
+ return this;
+ }
+}
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
index 861263574..d49b6c3a1 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONStringer.java
@@ -57,7 +57,10 @@ import java.util.List;
* self-use by overrideable methods is not specified. See <i>Effective Java</i>
* Item 17, "Design and Document or inheritance or else prohibit it" for further
* information.
+ *
+ * @deprecated in 5.10.0 without a replacement.
*/
+@Deprecated
class JSONStringer {
/**
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
index e0c4dac3c..225c8c26c 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/JSONTokener.java
@@ -476,14 +476,14 @@ class JSONTokener {
case ',':
case ';':
/* A separator without a value first means "null". */
- result.put(JSONObject.NULL);
+ result.add(JSONObject.NULL);
hasTrailingSeparator = true;
continue;
default:
pos--;
}
- result.put(nextValue(null));
+ result.add(nextValue(null));
switch (nextCleanInternal()) {
case ']':
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
index 8f0c5f31b..729f0c39b 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/PrettyPrintSession.java
@@ -1,4 +1,4 @@
-// Copyright 2010 The Apache Software Foundation
+// Copyright 2010, 2026 The Apache Software Foundation
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@@ -18,12 +18,12 @@ import java.io.PrintWriter;
/**
* Used to pretty-print JSON content, with a customizable indentation.
- *
+ *
* @since 5.2.0
*/
class PrettyPrintSession implements JSONPrintSession
{
- private final PrintWriter writer;
+ private final JSONPrintSink out;
private final String indentString;
@@ -36,10 +36,16 @@ class PrettyPrintSession implements JSONPrintSession
private Position position = Position.MARGIN;
+ /** Defaults the indentation to be two spaces per indentation level. */
+ PrettyPrintSession(StringBuilder out)
+ {
+ this(new StringBuilderPrintSink(out), " ");
+ }
+
/** Defaults the indentation to be two spaces per indentation level. */
public PrettyPrintSession(PrintWriter writer)
{
- this(writer, " ");
+ this(new WriterPrintSink(writer), " ");
}
/**
@@ -50,7 +56,12 @@ class PrettyPrintSession implements JSONPrintSession
*/
public PrettyPrintSession(PrintWriter writer, String indentString)
{
- this.writer = writer;
+ this(new WriterPrintSink(writer), indentString);
+ }
+
+ private PrettyPrintSession(JSONPrintSink out, String indentString)
+ {
+ this.out = out;
this.indentString = indentString;
}
@@ -67,7 +78,7 @@ class PrettyPrintSession implements JSONPrintSession
{
if (position != Position.MARGIN)
{
- writer.write('\n');
+ out.append('\n');
position = Position.MARGIN;
}
@@ -87,7 +98,7 @@ class PrettyPrintSession implements JSONPrintSession
if (position == Position.MARGIN)
{
for (int i = 0; i < indentLevel; i++)
- writer.print(indentString);
+ out.append(indentString);
position = Position.INDENTED;
}
@@ -97,7 +108,7 @@ class PrettyPrintSession implements JSONPrintSession
{
if (position == Position.CONTENT)
{
- writer.print(' ');
+ out.append(' ');
}
}
@@ -113,7 +124,7 @@ class PrettyPrintSession implements JSONPrintSession
{
prepareToPrint();
- writer.print(value);
+ out.append(value);
position = Position.CONTENT;
@@ -123,7 +134,15 @@ class PrettyPrintSession implements JSONPrintSession
@Override
public JSONPrintSession printQuoted(String value)
{
- return print(JSONObject.quote(value));
+ prepareToPrint();
+
+ out.append('"');
+ JSONObject.escapeInto(out, value);
+ out.append('"');
+
+ position = Position.CONTENT;
+
+ return this;
}
@Override
@@ -134,7 +153,7 @@ class PrettyPrintSession implements JSONPrintSession
if (symbol != ',')
addSep();
- writer.print(symbol);
+ out.append(symbol);
return this;
}
diff --git a/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java b/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
index 464082c05..2d5a6715a 100644
--- a/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
+++ b/tapestry-json/src/main/java/org/apache/tapestry5/json/package-info.java
@@ -13,7 +13,7 @@
/**
* Repackaged, improved (and tested) version of code originally https://github.com/tdunning/open-json
*
- * Starting in release 5.4, {@link org.apache.tapestry5.json.JSONObject} and {@link org.apache.tapestry5.json.JSONArray}
+ * Starting in release 5.4, {@link org.apache.tapestry5.json.improved.JSONObject} and {@link org.apache.tapestry5.json.improved.JSONArray}
* are serializable.
*/
-package org.apache.tapestry5.json;
+package org.apache.tapestry5.json.improved;
diff --git a/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java b/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java
new file mode 100644
index 000000000..64689de9d
--- /dev/null
+++ b/tapestry-json/src/test/java/org/apache/tapestry5/json/JSONObjectTest.java
@@ -0,0 +1,896 @@
+// Copyright 2025, 2026 The Apache Software Foundation
+//
+// Licensed 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.tapestry5.json;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.CharArrayWriter;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.PrintWriter;
+import java.math.BigDecimal;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.tapestry5.json.exceptions.JSONInvalidTypeException;
+import org.apache.tapestry5.json.exceptions.JSONSyntaxException;
+import org.apache.tapestry5.json.exceptions.JSONTypeMismatchException;
+import org.apache.tapestry5.json.exceptions.JSONValueNotFoundException;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+public class JSONObjectTest {
+
+ @Test
+ void copyAnExistingJSONObject() {
+
+ JSONObject master = new JSONObject("fred", "flintstone", "barney", "rubble");
+
+ JSONObject emptyCopy = new JSONObject(master);
+
+ assertTrue(emptyCopy.keys().isEmpty());
+
+ JSONObject limitedCopy = new JSONObject(master, "fred");
+
+ assertEquals(1, limitedCopy.keys().size());
+ assertEquals("flintstone", limitedCopy.get("fred"));
+
+ JSONObject fullCopy = new JSONObject(master, "fred", "barney");
+
+ assertEquals("{\"fred\":\"flintstone\",\"barney\":\"rubble\"}", fullCopy.toCompactString());
+ }
+
+ @Test
+ void copyAllPropertiesOfJSONObject() {
+
+ JSONObject master = new JSONObject("fred", "flintstone", "barney", "rubble");
+
+ JSONObject fullCopy = master.copy();
+
+ assertEquals(master, fullCopy);
+
+ master.put("wilma", "flintstone");
+
+ assertNotEquals(master, fullCopy);
+ }
+
+ @Test
+ void unknownKeysWhenCopyingAJSONObjectAreIgnored() {
+
+ JSONObject master = new JSONObject("fred", "flintstone", "barney", "rubble");
+
+ JSONObject copy = new JSONObject(master, "fred", "wilma");
+
+ assertEquals(1, copy.keys().size());
+ assertEquals("flintstone", copy.get("fred"));
+ }
+
+ @Test
+ void getWithUnknownKeyIsAnException() {
+
+ JSONObject master = new JSONObject("fred", "flintstone");
+
+ JSONValueNotFoundException e = assertThrows(JSONValueNotFoundException.class, () -> {
+ master.get("barney");
+ });
+
+ assertTrue(e.getMessage().startsWith("JSONObject[\"barney\"] is not found."));
+ assertTrue(e.getMessage().contains("Required: ANY"));
+ }
+
+ @Test
+ void getOrDefaultReturnsDefaultValueIfNotFound() {
+
+ JSONObject master = new JSONObject("fred", "flintstone");
+
+ Object result = master.getOrDefault("barney", "gumble");
+
+ assertEquals("gumble", result);
+ }
+
+ @Test
+ void getOrDefaultReturnsValueIfFound() {
+
+ JSONObject master = new JSONObject("fred", "flintstone");
+
+ Object result = master.getOrDefault("fred", "other");
+
+ assertEquals("flintstone", result);
+ }
+
+ // Data provider for ensureThatStoredValueIsExpected
+ static Stream<Arguments> storedValueProvider() {
+ return Stream.of(
+ Arguments.of("true", true, "Boolean"),
+ Arguments.of("TRUE", true, "Boolean"),
+ Arguments.of("false", false, "Boolean"),
+ Arguments.of("FALSE", false, "Boolean"),
+ Arguments.of(Boolean.TRUE, true, "Boolean"),
+ Arguments.of(Boolean.FALSE, false, "Boolean"),
+ Arguments.of(3.5d, 3.5d, "Double"),
+ Arguments.of(1_000L, 1_000d, "Double"),
+ Arguments.of("-101.7", -101.7d, "Double"),
+ Arguments.of("3", 3, "Int"),
+ Arguments.of(97L, 97, "Int"),
+ Arguments.of("-8.76", -8, "Int"),
+ Arguments.of(92d, 92L, "Long"),
+ Arguments.of("-200", -200L, "Long"),
+ Arguments.of(123, "123", "String")
+ );
+ }
+
+ @ParameterizedTest(name = "ensure that stored value {0} ({argumentsWithNames}) is {2} {1}")
+ @MethodSource("storedValueProvider")
+ void ensureThatStoredValueIsExpected(Object value, Object expected, String expectedType) {
+
+ JSONObject object = new JSONObject().put("key", value);
+ Function<String, Object> fn;
+ switch (expectedType) {
+ case "Boolean":
+ fn = object::getBoolean;
+ break;
+ case "Double":
+ fn = object::getDouble;
+ break;
+ case "Int":
+ fn = object::getInt;
+ break;
+ case "Long":
+ fn = object::getLong;
+ break;
+ case "String":
+ fn = object::getString;
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown expected type: " + expectedType);
+ }
+
+ Object result = fn.apply("key");
+
+ assertEquals(expected, result);
+ }
+
+
+ static Stream<Arguments> doubleToStringProvider() {
+ return Stream.of(
+ Arguments.of(3d, "3"),
+ Arguments.of(-22.5d, "-22.5"),
+ Arguments.of(0d, "0"),
+ Arguments.of(Double.NaN, "null"),
+ Arguments.of(Double.NEGATIVE_INFINITY, "null"),
+ Arguments.of(Double.POSITIVE_INFINITY, "null")
+ );
+ }
+
+ @ParameterizedTest(name = "conversion of double {0} to string should be ''{1}''")
+ @MethodSource("doubleToStringProvider")
+ void doubleToStringConversion(double input, String expected) {
+
+ assertEquals(expected, JSONObject.doubleToString(input));
+ }
+
+ @Test
+ void exceptionWhenGetBooleanOnAValueThatIsNotABoolean() {
+
+ JSONObject object = new JSONObject().put("key", 37);
+
+ JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+ object.getBoolean("key");
+ });
+
+ assertTrue(e.getMessage().startsWith("JSONObject[\"key\"] is not a BOOLEAN."));
+ }
+
+ @Test
+ void accumulateSimpleValues() {
+
+ JSONObject object = new JSONObject();
+ String key = "acckey";
+
+ object.accumulate(key, "alpha");
+
+ assertEquals("{\"acckey\":\"alpha\"}", object.toCompactString());
+
+ object.accumulate(key, "beta");
+ object.accumulate(key, "gamma");
+
+ assertEquals("{\"acckey\":[\"alpha\",\"beta\",\"gamma\"]}", object.toCompactString());
+ assertEquals(3, object.getJSONArray(key).size());
+ }
+
+ @Test
+ void accumulateWhenTheStoredValueIsAlreadyAnArrayJustAppendsTheValue() {
+
+ JSONObject object = new JSONObject();
+
+ object.accumulate("key", new JSONArray("fred", "barney"));
+
+ assertEquals("{\"key\":[\"fred\",\"barney\"]}", object.toCompactString());
+
+ object.accumulate("key", "wilma");
+
+ assertEquals("{\"key\":[\"fred\",\"barney\",\"wilma\"]}", object.toCompactString());
+ }
+
+ @Test
+ void canCreateAJSONObjectFromAJSONString() {
+
+ JSONObject object = new JSONObject("{ fred: \"flintstone\", caveman: true, friends: [\"barney\"] }");
+
+ assertEquals("flintstone", object.get("fred"));
+ assertTrue(object.getBoolean("caveman"));
+
+ JSONArray friends = object.getJSONArray("friends");
+
+ assertEquals(1, friends.size());
+ assertEquals("barney", friends.get(0));
+ }
+
+ @Test
+ void appendToAnUnknownKeyCreatesAJSONArrayForTheValue() {
+
+ JSONObject object = new JSONObject();
+
+ object.append("friends", "barney");
+
+ assertEquals("{\"friends\":[\"barney\"]}", object.toCompactString());
+ }
+
+ @Test
+ void appendToAnExistingKeyContainingAJSONArrayAppendsToTheArray() {
+
+ JSONObject object = new JSONObject("{friends: [\"barney\"] }");
+
+ object.append("friends", "zaphod");
+
+ assertEquals("{\"friends\":[\"barney\",\"zaphod\"]}", object.toCompactString());
+ }
+
+ @Test
+ void appendingToAKeyWhoseValueIsNotAArrayIsAnException() {
+
+ JSONObject object = new JSONObject("{friends: 0 }");
+
+ JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+ object.append("friends", "zaphod");
+ });
+
+ assertTrue(e.getMessage().startsWith("JSONObject[\"friends\"] is not a ARRAY."));
+ }
+
+ @Test
+ void getDoubleWithANonNumericValueIsAnException() {
+
+ JSONObject object = new JSONObject("{notdouble: true}");
+
+ JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+ object.getDouble("notdouble");
+ });
+
+ assertTrue(e.getMessage().startsWith("JSONObject[\"notdouble\"] is not a NUMBER."));
+ }
+
+ @Test
+ void getDoubleWithAStringThatCanNotBeParsedAsANumberIsAnException() {
+
+ JSONObject object = new JSONObject("{notdouble: \"this is a fact\"}");
+
+ JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+ object.getDouble("notdouble");
+ });
+
+ assertTrue(e.getMessage().startsWith("JSONObject[\"notdouble\"] is not a NUMBER."));
+ }
+
+ @Test
+ void hasWillIdentifyWhichKeysHaveValuesAndWhichDoNot() {
+
+ JSONObject object = new JSONObject("{fred: 'flintstone'}");
+
+ assertTrue(object.containsKey("fred"));
+ assertFalse(object.containsKey("barney"));
+ }
+
+ @Test
+ void getJSONArrayForAValueThatIsNotAArrayIsAnException() {
+
+ JSONObject object = new JSONObject("{notarray: 22.7}");
+
+ JSONTypeMismatchException e = assertThrows(JSONTypeMismatchException.class, () -> {
+ object.getJSONArray("notarray");
+ });
+
+ assertTrue(e.getMessage().startsWith("JSONObject[\"notarray\"] is not a ARRAY."));
+ }
+
+ @Test
+ void lengthOfAJSONObjectIsTheNumberOfKeys() {
+
+ JSONObject object = new JSONObject();
+
+ assertEquals(0, object.size());
+
+ object.put("fred", "flintstone");
+
+ assertEquals(1, object.size());
+
+ object.accumulate("fred", "murray");
+
+ assertEquals(1, object.size());
+
+ object.put("barney", "rubble");
+
+ assertEquals(2, object.size());
+ }
+
+ @Test
+ void namesReturnsTheKeysAsAJSONArray() {
+
+ JSONObject object = new JSONObject("{fred: 'flintstone', barney: 'rubble' }");
+
+ JSONArray names = object.names();
+
+ assertNotNull(names);
+ assertTrue(names instanceof JSONArray);
+ assertEquals(2, names.size());
+
+ List<Object> nameList = new ArrayList<>(names.toList());
+ Collections.sort(nameList, (o1, o2) -> ((String) o1).compareTo((String) o2));
+
+ assertEquals(Arrays.asList("barney", "fred"), nameList);
+ }
+
+ @Test
+ void namesWithNoPropertiesReturnsNull() {
+
+ JSONObject object = new JSONObject();
+
+ assertNull(object.names());
+ }
+
+ @Test
+ void theNullLiteralIsTheNULLObject() {
+
+ JSONObject object = new JSONObject("{nullkey: null}");
+
+ assertSame(JSONObject.NULL, object.get("nullkey"));
+ assertTrue(object.isNull("nullkey"));
+ }
+
+ @Test
+ void theNULLObjectIsOutputAsNullLiteral() {
+
+ JSONObject object = new JSONObject().put("nullkey", JSONObject.NULL);
+
+ assertTrue(object.isNull("nullkey"));
+ assertEquals("{\"nullkey\":null}", object.toCompactString());
+ }
+
+ @Test
+ void theNULLObjectMatchesJavasNull() {
+
+ assertTrue(JSONObject.NULL.equals(null));
+ }
+
+ static Stream<Arguments> invalidInputProvider() {
+ return Stream.of(
+ Arguments.of("{ ", "A JSONObject text must end with '}' at character 3", "unmatched open brace"),
+ Arguments.of("fred", "A JSONObject text must start with '{' (actual: 'f') at character 1 of", "missing open brace"),
+ Arguments.of("{ \"akey\" }", "Expected a ':' after a key at character 10 of", "missing value after key"),
+ Arguments.of("{ \"fred\" : 1 \"barney\" }", "Expected a ',' or '}' at character 14 of", "missing property separator"),
+ Arguments.of("{ \"list\" : [1, 2", "Expected a ',' or ']' at character 16 of", "missing seperator or closing bracket"),
+ Arguments.of("/* unclosed", "Unclosed comment at character 11 of", "unclosed C-style comment"),
+ Arguments.of("{ \"fred \\n}", "Unterminated string at character 11 of", "unterminated string at line end"),
+ Arguments.of("{ fred: ,}", "Missing value at character 8 of ", "missing value after key")
+ );
+ }
+
+ @ParameterizedTest(name = "invalid input ({2}) throws exception")
+ @MethodSource("invalidInputProvider")
+ void invalidInputThrowsException(String input, String expected, String desc) {
+ // When/Then
+ JSONSyntaxException e = assertThrows(JSONSyntaxException.class, () -> {
+ new JSONObject(input);
+ });
+
+ assertTrue(e.getMessage().trim().startsWith(expected),
+ "Expected message to start with: " + expected + ", but was: " + e.getMessage().trim());
+ }
+
+
+ @Test
+ void canUseColonOrArrowAsKeySeperatorAndSemicolonAsPropertySeparator() {
+ JSONObject object = new JSONObject("{ \"fred\" = 1; \"barney\" => 2}");
+
+ assertEquals(1, object.getInt("fred"));
+ assertEquals(2, object.getInt("barney"));
+ }
+
+ static Stream<Arguments> numberToStringConversionProvider() {
+ return Stream.of(
+ Arguments.of(new BigDecimal("100.0000000"), "100")
+ );
+ }
+
+ @ParameterizedTest(name = "conversion of {0} ({argumentsWithNames}) to string is {1}")
+ @MethodSource("numberToStringConversionProvider")
+ void numberToStringConversion(Number value, String expected) {
+
+ assertEquals(expected, JSONObject.numberToString(value));
+ }
+
+ static Stream<Arguments> quoteProvider() {
+ return Stream.of(
+ Arguments.of(null, "\"\"", "null is empty string"),
+ Arguments.of("", "\"\"", "empty string is empty string"),
+ Arguments.of("\"/\b\t\n\f\r\u2001/a</", "\"\\\"/\\b\\t\\n\\f\\r\\u2001/a<\\/\"", "special characters"),
+ Arguments.of(
+ " \u0080\u009f\u00a0\u1fff\u2000\u20ff\u2100",
+ "\"\\u001f \\u0080\\u009f\u00a0\u1fff\\u2000\\u20ff\u2100\"",
+ "escape range boundaries: 0x1F/0x20, 0x7F/0x80/0x9F/0xA0, 0x1FFF/0x2000/0x20FF/0x2100"
+ )
+ );
+ }
+
+ @ParameterizedTest(name = "quote(): {2}")
+ @MethodSource("quoteProvider")
+ void quoteTest(String value, String expected, String desc) {
+
+ assertEquals(expected, JSONObject.quote(value));
+ }
+
+ static Stream<Arguments> nonFiniteNumbersProvider() {
+ return Stream.of(
+ Arguments.of(Double.NaN),
+ Arguments.of(Double.NEGATIVE_INFINITY),
+ Arguments.of(Double.POSITIVE_INFINITY),
+ Arguments.of(Float.NaN),
+ Arguments.of(Float.NEGATIVE_INFINITY),
+ Arguments.of(Float.POSITIVE_INFINITY)
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("nonFiniteNumbersProvider")
+ void nonFiniteNumbersAreNotAllowed(Number value) {
+
+ JSONObject object = new JSONObject();
+
+ RuntimeException e = assertThrows(IllegalArgumentException.class, () -> {
+ object.put("nonfinite", value);
+ });
+
+ assertEquals("JSON does not allow non-finite numbers.", e.getMessage());
+ }
+
+ @Test
+ void parseAnEmptyObjectIsEmpty() {
+ JSONObject object = new JSONObject("{}");
+ assertEquals(0, object.size());
+ }
+
+ @Test
+ void putAJavaNullIsARemove() {
+
+ JSONObject object = new JSONObject("{fred:'flintstone', barney:'rubble'}");
+
+ object.put("fred", null);
+
+ assertEquals("{\"barney\":\"rubble\"}", object.toCompactString());
+ }
+
+ static Stream<Arguments> validTypesForPutProvider() {
+ return Stream.of(
+ Arguments.of((Object) null),
+ Arguments.of(true),
+ Arguments.of(3),
+ Arguments.of(3.5),
+ Arguments.of("*VALUE*"),
+ Arguments.of(new JSONLiteral("*LITERAL*")),
+ Arguments.of(new JSONObject()),
+ Arguments.of(new JSONArray())
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("validTypesForPutProvider")
+ void onlySpecificObjectTypesMayBeAddedNoException(Object value) {
+
+ JSONObject object = new JSONObject("{}");
+
+ assertDoesNotThrow(() -> object.put("key", value));
+ }
+
+
+ static Stream<Arguments> invalidTypesForPutProvider() {
+ return Stream.of(
+ Arguments.of(new java.util.Date()),
+ Arguments.of((Object) new Object[] {}),
+ Arguments.of(Collections.emptyMap())
+ );
+ }
+ @ParameterizedTest
+ @MethodSource("invalidTypesForPutProvider")
+ void onlySpecificObjectTypesMayBeAddedException(Object value) {
+
+ JSONObject object = new JSONObject("{}");
+
+ assertThrows(JSONInvalidTypeException.class, () -> object.put("key", value));
+ }
+
+ @Test
+ void JSONStringCanOutputAnythingItWants() {
+ JSONString string = () -> "*VALUE*";
+ JSONObject object = new JSONObject();
+
+ object.put("key", string);
+
+ assertEquals("{\"key\":*VALUE*}", object.toCompactString());
+ }
+
+ @Test
+ void equalsImplementation() {
+ String jsonStr = "{ \"key\" : 99 }";
+ JSONObject obj1 = new JSONObject(jsonStr);
+ JSONObject obj2 = new JSONObject(jsonStr);
+
+ assertEquals(obj1, obj2);
+ assertEquals(obj2, obj1);
+ assertNotEquals(null, obj1);
+ assertNotEquals(jsonStr, obj1);
+
+ obj2.put("screw", "the pooch");
+
+ assertNotEquals(obj1, obj2);
+ }
+
+ @Test
+ void hashCodeImplementation() {
+
+ String jsonStr = "{ \"key\" : 99 }";
+ JSONObject obj1 = new JSONObject(jsonStr);
+ JSONObject obj2 = new JSONObject(jsonStr);
+
+ assertEquals(obj1.hashCode(), obj2.hashCode());
+
+ obj2.put("screw", "the pooch");
+
+ assertNotEquals(obj1.hashCode(), obj2.hashCode());
+ }
+
+ @Test
+ void escapedCharactersInTheJSONAreParsedCorrectly() {
+
+ JSONObject object = new JSONObject("{ \"key\" : \"\\\"/\\b\\t\\n\\f\\r\\u2001/a<\\/\\x20\" }");
+
+ assertEquals("\"/\b\t\n\f\r\u2001/a</ ", object.get("key"));
+ }
+
+ @Test
+ void parseANestedObject() {
+
+ JSONObject object = new JSONObject("{ key: { name: \"inner\" }}");
+
+ assertEquals("inner", object.getJSONObject("key").getString("name"));
+ }
+
+ @Test
+ void parseBooleanLiterals() {
+
+ JSONObject object = new JSONObject("{ t: true, f: false }");
+
+ assertTrue(object.getBoolean("t"));
+ assertFalse(object.getBoolean("f"));
+ }
+
+ @Test
+ void parseNumericForms() {
+
+ JSONObject object = new JSONObject("{ hex: 0x50, oct: 030, posInt: +50, negInt: -50, long: 4294968530, float: -32.7 }");
+
+ assertEquals(80, object.getInt("hex"));
+ assertEquals(24, object.getInt("oct"));
+ assertEquals(50, object.getInt("posInt"));
+ assertEquals(-50, object.getInt("negInt"));
+ assertEquals(4294968530L, object.getLong("long"));
+ assertEquals(-32.7d, object.getDouble("float"));
+ }
+
+ @Test
+ void NULLToStringIsNullKeyword() {
+
+ assertEquals("null", JSONObject.NULL.toString());
+ }
+
+ @Test
+ void JSONLiteralCanOutputAnyContent() {
+
+ String text = "function(x) { $('bar').show(); }";
+ JSONLiteral literal = new JSONLiteral(text);
+ JSONObject object = new JSONObject().put("callback", literal);
+
+ assertEquals("{\"callback\":" + text + "}", object.toCompactString());
+ assertEquals(text, literal.toString());
+ }
+
+ @Test
+ void prettyPrintTest() {
+
+ JSONObject object = new JSONObject("fred", "flintstone", "barney", "rubble");
+ String expected = "{\n \"fred\" : \"flintstone\",\n \"barney\" : \"rubble\"\n}";
+
+ assertEquals(expected.trim(), object.toString().trim());
+ }
+
+ @Test
+ void cStyleCommentsAreIgnored() {
+
+ JSONObject object = new JSONObject("/* C comment */ { \"foo\" : \"bar\" /* *ignored* */ }");
+
+ assertEquals("bar", object.get("foo"));
+ }
+
+ @Test
+ void doubleSlashCommentsExtendToEndOfLine() {
+
+ JSONObject object = new JSONObject("{ foo: // This is a double-slash comment\n\"bar\" }");
+
+ assertEquals("bar", object.get("foo"));
+ }
+
+ @Test
+ void hashCommentsExtendToEndOfLine() {
+
+ JSONObject object = new JSONObject("{ foo: # This is a hash comment\n\"bar\" }");
+
+ assertEquals("bar", object.get("foo"));
+ }
+
+ @Test
+ void prettyPrintAnEmptyJSONObject() {
+
+ JSONObject object = new JSONObject();
+
+ assertEquals("{}", object.toString());
+ }
+
+ @Test
+ void complexPrettyPrint() {
+ JSONObject object = new JSONObject(
+ "{ outer: { inner: \"here\" },\n" +
+ " number: 22.7,\n" +
+ " array: [1, 2, 3],\n" +
+ " boolean: true,\n" +
+ " otherwise: 'a quoted string'\n" +
+ "}"
+ );
+ String expected = "{\n" +
+ " \"outer\" : {\n" +
+ " \"inner\" : \"here\"\n" +
+ " },\n" +
+ " \"number\" : 22.7,\n" +
+ " \"array\" : [\n" +
+ " 1,\n" +
+ " 2,\n" +
+ " 3\n" +
+ " ],\n" +
+ " \"boolean\" : true,\n" +
+ " \"otherwise\" : \"a quoted string\"\n" +
+ "}";
+ assertEquals(expected.trim(), object.toString().trim());
+ }
+
+
+ @Test
+ void toCompactStringTest() {
+
+ JSONObject object = new JSONObject("kermit", "frog");
+
+ assertEquals("{\"kermit\":\"frog\"}", object.toString(true));
+ assertEquals("{\n \"kermit\" : \"frog\"\n}", object.toString(false).replace("\r\n", "\n"));
+ }
+
+ private static String withPrintWriter(Consumer<PrintWriter> closure) {
+
+ CharArrayWriter caw = new CharArrayWriter();
+ try (PrintWriter pw = new PrintWriter(caw)) {
+ closure.accept(pw);
+ }
+
+ return caw.toString();
+ }
+
+ private static String print(JSONObject object, boolean compact) {
+ return withPrintWriter(pw -> object.print(pw, compact));
+ }
+
+ @Test
+ void printToPrintWriterTest() {
+
+ JSONObject object = new JSONObject("kermit", "frog");
+
+ assertEquals("{\"kermit\":\"frog\"}", print(object, true));
+ assertEquals("{\n \"kermit\" : \"frog\"\n}", print(object, false).replace("\r\n", "\n"));
+ }
+
+
+ @Test
+ void prettyPrintToPrintWriter() {
+
+ JSONObject object = new JSONObject("kermit", "frog");
+
+ String json = withPrintWriter(pw -> object.prettyPrint(pw));
+ assertEquals("{\n \"kermit\" : \"frog\"\n}", json.replace("\r\n", "\n"));
+ }
+
+ @Test
+ void getStringAtIndex() {
+
+ JSONArray array = new JSONArray("one", 2, false);
+
+ assertEquals("one", array.getString(0));
+ assertEquals("2", array.getString(1));
+ assertEquals("false", array.getString(2));
+ }
+
+ @Test
+ void toMapIsNotModifiable() {
+
+ JSONObject object = new JSONObject();
+ Map<String, Object> map = object.toMap();
+
+ assertThrows(UnsupportedOperationException.class, map::clear);
+ }
+
+ @Test
+ void canAccessContentsOfObjectAsAMap() {
+
+ JSONObject object = new JSONObject("foo", "bar")
+ .put("null", JSONObject.NULL)
+ .put("number", 6);
+ Map<String, Object> map = object.toMap();
+
+ assertEquals("bar", map.get("foo"));
+ assertEquals(6, map.get("number"));
+ assertSame(JSONObject.NULL, map.get("null"));
+ }
+
+ @Test
+ void theMapReturnedByToMapIsLive() {
+
+ JSONObject object = new JSONObject("foo", "bar");
+ Map<String, Object> map = object.toMap();
+
+ object.put("foo", null);
+
+ assertEquals(0, object.size());
+ assertTrue(map.isEmpty());
+ }
+
+ @Test
+ void canAddNewPropertiesViaPutAll() {
+
+ JSONObject object = new JSONObject("fred", "flintstone");
+ Map<String, Object> newProps = new HashMap<>();
+ newProps.put("barney", "rubble");
+ newProps.put("wilma", "flintstone");
+
+ object.putAll(newProps);
+
+ assertTrue(object.keySet().containsAll(Arrays.asList("fred", "barney", "wilma")));
+ assertEquals(
+ Stream.of("flintstone", "rubble", "flintstone").sorted().collect(Collectors.toList()),
+ object.values().stream().sorted().collect(Collectors.toList())
+ );
+ }
+
+ @Test
+ void inWhereTheValueIsNotJSONObjectIsAnException() {
+
+ JSONObject object = new JSONObject("time", "to rock");
+
+ IllegalStateException e = assertThrows(IllegalStateException.class, () -> {
+ object.in("time");
+ });
+
+ assertEquals("JSONObject[\"time\"] is not a JSONObject.", e.getMessage());
+ }
+
+ @Test
+ void inCreatesAJSONObjectIfNecessary() {
+
+ JSONObject object = new JSONObject();
+
+ object.in("nested").put("created", true);
+
+ assertEquals("{\"nested\":{\"created\":true}}", object.toCompactString());
+ }
+
+ @Test
+ void inReturnsTheJSONObject() {
+
+ JSONObject object = new JSONObject("{nested: {}}");
+
+ object.in("nested").put("time", "to rock");
+
+ assertEquals("{\"nested\":{\"time\":\"to rock\"}}", object.toCompactString());
+ }
+
+ @Test
+ void constructorSupportsNonStringKeysAndValues() {
+
+ JSONObject object = new JSONObject(true, "truthy", false, false);
+
+ assertTrue(object.containsKey("true")); // Keys are stringified
+ assertEquals("truthy", object.getString("true"));
+ assertTrue(object.containsKey("false"));
+ assertEquals(false, object.get("false"));
+ }
+
+
+ private static Object copyViaSerialization(Object source) throws IOException, ClassNotFoundException {
+
+ ByteArrayOutputStream bos = new ByteArrayOutputStream();
+ try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
+ oos.writeObject(source);
+ }
+
+ ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
+ try (ObjectInputStream ois = new ObjectInputStream(bis)) {
+ return ois.readObject();
+ }
+ }
+
+ @Test
+ void serializeAndDeSerialize() throws IOException, ClassNotFoundException {
+
+ JSONObject source = new JSONObject("string", "a string", "null", JSONObject.NULL);
+
+ JSONObject copy = (JSONObject) copyViaSerialization(source);
+
+ assertEquals(source, copy);
+ }
+
+ // TAP5-2759
+ @Test
+ void getLongOrDefaultCorrectTypeForDefaultValue() {
+ JSONObject object = new JSONObject();
+ long defaultValue = 2_147_483_648L; // This is Integer.MAX_VALUE + 1, so it's a long
+ long value = object.getLongOrDefault("key", defaultValue);
+
+ assertEquals(defaultValue, value);
+ }
+}