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 <[email protected]> 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); + } +}
