From 293bbf65d335921d56a840dd6b0d2840d4617c84 Mon Sep 17 00:00:00 2001 From: labkey-jeckels Date: Tue, 20 Oct 2026 09:54:00 -0700 Subject: [PATCH] Share repeated object keys within a parse (#1091) --- src/main/java/org/json/JSONObject.java | 2 +- .../org/json/JSONParserConfiguration.java | 45 ++++++ src/main/java/org/json/JSONTokener.java | 30 ++++ .../junit/JSONParserConfigurationTest.java | 138 ++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/json/JSONObject.java b/src/main/java/org/json/JSONObject.java index 3ded23f61..9029c8e66 100644 --- a/src/main/java/org/json/JSONObject.java +++ b/src/main/java/org/json/JSONObject.java @@ -276,7 +276,7 @@ private boolean parseJSONObject(JSONTokener jsonTokener, JSONParserConfiguration return true; default: obj = jsonTokener.nextSimpleValue(c); - key = obj.toString(); + key = jsonTokener.shareKey(obj.toString(), jsonParserConfiguration.getMaxSharedKeys()); } checkKeyForStrictMode(jsonTokener, jsonParserConfiguration, obj); diff --git a/src/main/java/org/json/JSONParserConfiguration.java b/src/main/java/org/json/JSONParserConfiguration.java index 0cfa2eaef..da5a66067 100644 --- a/src/main/java/org/json/JSONParserConfiguration.java +++ b/src/main/java/org/json/JSONParserConfiguration.java @@ -4,6 +4,16 @@ * Configuration object for the JSON parser. The configuration is immutable. */ public class JSONParserConfiguration extends ParserConfiguration { + /** + * The default maximum number of distinct object keys shared within one parse. + */ + public static final int DEFAULT_MAXIMUM_SHARED_KEYS = 1000; + + /** + * Used to indicate that there is no limit on the number of distinct object keys shared within one parse. + */ + public static final int UNLIMITED_SHARED_KEYS = -1; + /** * Used to indicate whether to overwrite duplicate key or not. */ @@ -14,12 +24,18 @@ public class JSONParserConfiguration extends ParserConfiguration { */ private boolean useNativeNulls; + /** + * The maximum number of distinct object keys shared within one parse. + */ + private int maxSharedKeys; + /** * Configuration with the default values. */ public JSONParserConfiguration() { super(); this.overwriteDuplicateKey = false; + this.maxSharedKeys = DEFAULT_MAXIMUM_SHARED_KEYS; // DO NOT DELETE THE FOLLOWING LINE -- it is used for strictMode testing // this.strictMode = true; } @@ -38,6 +54,7 @@ protected JSONParserConfiguration clone() { clone.maxNestingDepth = maxNestingDepth; clone.keepStrings = keepStrings; clone.useNativeNulls = useNativeNulls; + clone.maxSharedKeys = maxSharedKeys; return clone; } @@ -89,6 +106,24 @@ public JSONParserConfiguration withUseNativeNulls(final boolean useNativeNulls) return clone; } + /** + * Defines how many distinct object keys the parser shares within one parse. When a key repeats, for example + * in each element of an array of objects, the parser reuses the first String instance read for that key + * instead of keeping a separate copy in every object. Only the first maxSharedKeys distinct keys + * are shared, so input that begins with many unique keys may need a higher limit. The default is 1000. + * Parsed values are the same with any setting. + * + * @param maxSharedKeys the maximum number of distinct keys to share; 0 disables sharing, and + * {@link #UNLIMITED_SHARED_KEYS} (or any negative value) removes the limit + * @return The existing configuration will not be modified. A new configuration is returned. + */ + public JSONParserConfiguration withMaxSharedKeys(final int maxSharedKeys) { + JSONParserConfiguration clone = this.clone(); + clone.maxSharedKeys = Math.max(maxSharedKeys, UNLIMITED_SHARED_KEYS); + + return clone; + } + /** * Sets the strict mode configuration for the JSON parser with default true value *

@@ -140,6 +175,16 @@ public boolean isUseNativeNulls() { } + /** + * The maximum number of distinct object keys shared within one parse. 0 means keys are not shared, + * and {@link #UNLIMITED_SHARED_KEYS} means there is no limit. + * + * @return The maxSharedKeys configuration value. + */ + public int getMaxSharedKeys() { + return this.maxSharedKeys; + } + /** * The parser throws an Exception when strict mode is true and tries to parse invalid JSON characters. * Otherwise, the parser is more relaxed and might tolerate some invalid characters. diff --git a/src/main/java/org/json/JSONTokener.java b/src/main/java/org/json/JSONTokener.java index 1acb73bf7..56d97cd19 100644 --- a/src/main/java/org/json/JSONTokener.java +++ b/src/main/java/org/json/JSONTokener.java @@ -2,6 +2,8 @@ import java.io.*; import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; /* Public Domain. @@ -35,6 +37,9 @@ public class JSONTokener { // access to this object is required for strict mode checking private JSONParserConfiguration jsonParserConfiguration; + /** object keys already read, so repeated keys can share one String instance. */ + private Map sharedKeys; + /** * Construct a JSONTokener from a Reader. The caller must close the Reader. * @@ -124,6 +129,31 @@ void setJsonParserConfigurationInternal(JSONParserConfiguration jsonParserConfig this.jsonParserConfiguration = jsonParserConfiguration; } + /** + * Returns an earlier String instance equal to the key if this tokener has read one, otherwise the key itself. + * + * @param key an object key that was just read + * @param maxSharedKeys the maximum number of distinct keys to remember; 0 disables sharing and a negative + * value means no limit + * @return a String equal to key + */ + String shareKey(String key, int maxSharedKeys) { + if (maxSharedKeys == 0) { + return key; + } + if (this.sharedKeys == null) { + this.sharedKeys = new HashMap(); + } + String shared = this.sharedKeys.get(key); + if (shared != null) { + return shared; + } + if (maxSharedKeys < 0 || this.sharedKeys.size() < maxSharedKeys) { + this.sharedKeys.put(key, key); + } + return key; + } + /** * Back up one character. This provides a sort of lookahead capability, * so that you can test for a digit or letter before attempting to parse diff --git a/src/test/java/org/json/junit/JSONParserConfigurationTest.java b/src/test/java/org/json/junit/JSONParserConfigurationTest.java index 21bb84115..d974d9374 100644 --- a/src/test/java/org/json/junit/JSONParserConfigurationTest.java +++ b/src/test/java/org/json/junit/JSONParserConfigurationTest.java @@ -16,9 +16,11 @@ import java.util.stream.Stream; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.fail; public class JSONParserConfigurationTest { @@ -708,4 +710,140 @@ private List getNonCompliantJSONObjectList() { ); } + private static final String SHARED_KEYS_SOURCE = "{\"rows\": [" + + "{\"name\": \"a\", \"count\": 1, \"big\": 12345678901, \"ratio\": 1.5, \"flag\": true, \"missing\": null," + + " \"nested\": {\"name\": \"inner\", \"list\": [1, \"name\", null]}}," + + "{\"name\": \"name\", \"count\": 2, \"nested\": {\"name\": \"inner2\", \"list\": []}}," + + "{\"name\": \"third\", \"count\": -3}" + + "]}"; + + @Test + public void testMaxSharedKeysConfig() { + JSONParserConfiguration config = new JSONParserConfiguration(); + assertEquals(JSONParserConfiguration.DEFAULT_MAXIMUM_SHARED_KEYS, config.getMaxSharedKeys()); + assertEquals(1000, config.getMaxSharedKeys()); + assertEquals(0, config.withMaxSharedKeys(0).getMaxSharedKeys()); + assertEquals(JSONParserConfiguration.UNLIMITED_SHARED_KEYS, config.withMaxSharedKeys(-1).getMaxSharedKeys()); + assertEquals(JSONParserConfiguration.UNLIMITED_SHARED_KEYS, config.withMaxSharedKeys(-5).getMaxSharedKeys()); + + JSONParserConfiguration chained = config.withMaxSharedKeys(7).withOverwriteDuplicateKey(true).withStrictMode(false); + assertEquals(7, chained.getMaxSharedKeys()); + assertTrue(chained.isOverwriteDuplicateKey()); + assertEquals(1000, config.getMaxSharedKeys()); + } + + @Test + public void testSharedKeysByDefault() { + JSONArray rows = new JSONObject(SHARED_KEYS_SOURCE).getJSONArray("rows"); + String nameKey = key(rows.getJSONObject(0), "name"); + for (int i = 1; i < rows.length(); i++) { + assertSame(nameKey, key(rows.getJSONObject(i), "name")); + } + assertSame(nameKey, key(rows.getJSONObject(0).getJSONObject("nested"), "name")); + assertSame(key(rows.getJSONObject(0), "count"), key(rows.getJSONObject(2), "count")); + assertSame(key(rows.getJSONObject(0).getJSONObject("nested"), "list"), + key(rows.getJSONObject(1).getJSONObject("nested"), "list")); + + // string values equal to a key are not shared + assertNotSame(nameKey, rows.getJSONObject(1).get("name")); + assertNotSame(nameKey, rows.getJSONObject(0).getJSONObject("nested").getJSONArray("list").get(1)); + } + + @Test + public void testSharedKeysDisabled() { + JSONArray rows = new JSONObject(SHARED_KEYS_SOURCE, new JSONParserConfiguration().withMaxSharedKeys(0)) + .getJSONArray("rows"); + assertNotSame(key(rows.getJSONObject(0), "name"), key(rows.getJSONObject(1), "name")); + assertNotSame(key(rows.getJSONObject(0), "count"), key(rows.getJSONObject(1), "count")); + } + + @Test + public void testSharedKeysLimit() { + JSONArray rows = new JSONArray("[{\"a\": 1, \"b\": 2, \"c\": 3}, {\"a\": 4, \"b\": 5, \"c\": 6}]", + new JSONParserConfiguration().withMaxSharedKeys(2)); + assertSame(key(rows.getJSONObject(0), "a"), key(rows.getJSONObject(1), "a")); + assertSame(key(rows.getJSONObject(0), "b"), key(rows.getJSONObject(1), "b")); + assertNotSame(key(rows.getJSONObject(0), "c"), key(rows.getJSONObject(1), "c")); + } + + @Test + public void testSharedKeysUnlimited() { + int keyCount = JSONParserConfiguration.DEFAULT_MAXIMUM_SHARED_KEYS + 500; + StringBuilder row = new StringBuilder("{"); + for (int i = 0; i < keyCount; i++) { + row.append(i == 0 ? "" : ",").append("\"k").append(i).append("\":").append(i); + } + row.append('}'); + String source = "[" + row + "," + row + "]"; + + JSONArray unlimited = new JSONArray(source, + new JSONParserConfiguration().withMaxSharedKeys(JSONParserConfiguration.UNLIMITED_SHARED_KEYS)); + for (int i = 0; i < keyCount; i++) { + assertSame(key(unlimited.getJSONObject(0), "k" + i), key(unlimited.getJSONObject(1), "k" + i)); + } + + JSONArray limited = new JSONArray(source); + String lastKey = "k" + (keyCount - 1); + assertSame(key(limited.getJSONObject(0), "k0"), key(limited.getJSONObject(1), "k0")); + assertNotSame(key(limited.getJSONObject(0), lastKey), key(limited.getJSONObject(1), lastKey)); + assertTrue(unlimited.similar(limited)); + } + + @Test + public void testSharedKeysNotSharedAcrossParses() { + JSONObject first = new JSONObject("{\"name\": 1}"); + JSONObject second = new JSONObject("{\"name\": 2}"); + assertNotSame(key(first, "name"), key(second, "name")); + } + + @Test + public void testSharedKeysParseUnchanged() { + JSONObject unshared = new JSONObject(SHARED_KEYS_SOURCE, new JSONParserConfiguration().withMaxSharedKeys(0)); + JSONObject shared = new JSONObject(SHARED_KEYS_SOURCE); + JSONObject unlimited = new JSONObject(SHARED_KEYS_SOURCE, + new JSONParserConfiguration().withMaxSharedKeys(JSONParserConfiguration.UNLIMITED_SHARED_KEYS)); + assertTrue(unshared.similar(shared)); + assertTrue(unshared.similar(unlimited)); + assertEquals(unshared.toString(), shared.toString()); + assertEquals(unshared.toString(), unlimited.toString()); + + JSONObject row = shared.getJSONArray("rows").getJSONObject(0); + assertEquals(Integer.class, row.get("count").getClass()); + assertEquals(Long.class, row.get("big").getClass()); + assertEquals(java.math.BigDecimal.class, row.get("ratio").getClass()); + assertEquals(Boolean.TRUE, row.get("flag")); + assertSame(JSONObject.NULL, row.get("missing")); + assertSame(JSONObject.NULL, row.getJSONObject("nested").getJSONArray("list").get(2)); + assertEquals("third", shared.getJSONArray("rows").getJSONObject(2).get("name")); + } + + @Test + public void testSharedKeysLenientSyntax() { + JSONArray rows = new JSONArray("[{\"name\": 1, \"count\": 2}, {'name': 'single', count: 3}]", + new JSONParserConfiguration().withStrictMode(false)); + assertSame(key(rows.getJSONObject(0), "name"), key(rows.getJSONObject(1), "name")); + assertSame(key(rows.getJSONObject(0), "count"), key(rows.getJSONObject(1), "count")); + assertEquals("single", rows.getJSONObject(1).get("name")); + } + + @Test + public void testSharedKeysStrictMode() { + JSONArray rows = new JSONArray("[{\"a\": 1}, {\"a\": 2}]", new JSONParserConfiguration().withStrictMode()); + assertSame(key(rows.getJSONObject(0), "a"), key(rows.getJSONObject(1), "a")); + assertFalse(rows.getJSONObject(0).similar(rows.getJSONObject(1))); + } + + /** + * Returns the key instance a JSONObject holds, rather than the caller's equal String. + */ + private static String key(JSONObject jsonObject, String name) { + for (String key : jsonObject.keySet()) { + if (key.equals(name)) { + return key; + } + } + fail("missing key " + name); + return null; + } + }