Skip to content

Commit a6ca840

Browse files
authored
Merge pull request #1020 from Abhineshhh/fix/support-java-records
Fix: Support Java record accessors in JSONObject
2 parents 8c14e96 + 8f3b0f1 commit a6ca840

File tree

3 files changed

+288
-3
lines changed

3 files changed

+288
-3
lines changed

src/main/java/org/json/JSONObject.java

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,18 @@ public Class<? extends Map> getMapType() {
144144
*/
145145
public static final Object NULL = new Null();
146146

147+
/**
148+
* Set of method names that should be excluded when identifying record-style accessors.
149+
* These are common bean/Object method names that are not property accessors.
150+
*/
151+
private static final Set<String> EXCLUDED_RECORD_METHOD_NAMES = Collections.unmodifiableSet(
152+
new HashSet<String>(Arrays.asList(
153+
"get", "is", "set",
154+
"toString", "hashCode", "equals", "clone",
155+
"notify", "notifyAll", "wait"
156+
))
157+
);
158+
147159
/**
148160
* Construct an empty JSONObject.
149161
*/
@@ -1823,11 +1835,14 @@ private void populateMap(Object bean, Set<Object> objectsRecord, JSONParserConfi
18231835
Class<?> klass = bean.getClass();
18241836

18251837
// If klass is a System class then set includeSuperClass to false.
1838+
1839+
// Check if this is a Java record type
1840+
boolean isRecord = isRecordType(klass);
18261841

18271842
Method[] methods = getMethods(klass);
18281843
for (final Method method : methods) {
18291844
if (isValidMethod(method)) {
1830-
final String key = getKeyNameFromMethod(method);
1845+
final String key = getKeyNameFromMethod(method, isRecord);
18311846
if (key != null && !key.isEmpty()) {
18321847
processMethod(bean, objectsRecord, jsonParserConfiguration, method, key);
18331848
}
@@ -1873,6 +1888,29 @@ private void processMethod(Object bean, Set<Object> objectsRecord, JSONParserCon
18731888
}
18741889
}
18751890

1891+
/**
1892+
* Checks if a class is a Java record type.
1893+
* This uses reflection to check for the isRecord() method which was introduced in Java 16.
1894+
* This approach works even when running on Java 6+ JVM.
1895+
*
1896+
* @param klass the class to check
1897+
* @return true if the class is a record type, false otherwise
1898+
*/
1899+
private static boolean isRecordType(Class<?> klass) {
1900+
try {
1901+
// Use reflection to check if Class has an isRecord() method (Java 16+)
1902+
// This allows the code to compile on Java 6 while still detecting records at runtime
1903+
Method isRecordMethod = Class.class.getMethod("isRecord");
1904+
return (Boolean) isRecordMethod.invoke(klass);
1905+
} catch (NoSuchMethodException e) {
1906+
// isRecord() method doesn't exist - we're on Java < 16
1907+
return false;
1908+
} catch (Exception e) {
1909+
// Any other reflection error - assume not a record
1910+
return false;
1911+
}
1912+
}
1913+
18761914
/**
18771915
* This is a convenience method to simplify populate maps
18781916
* @param klass the name of the object being checked
@@ -1885,10 +1923,11 @@ private static Method[] getMethods(Class<?> klass) {
18851923
}
18861924

18871925
private static boolean isValidMethodName(String name) {
1888-
return !"getClass".equals(name) && !"getDeclaringClass".equals(name);
1926+
return !"getClass".equals(name)
1927+
&& !"getDeclaringClass".equals(name);
18891928
}
18901929

1891-
private static String getKeyNameFromMethod(Method method) {
1930+
private static String getKeyNameFromMethod(Method method, boolean isRecordType) {
18921931
final int ignoreDepth = getAnnotationDepth(method, JSONPropertyIgnore.class);
18931932
if (ignoreDepth > 0) {
18941933
final int forcedNameDepth = getAnnotationDepth(method, JSONPropertyName.class);
@@ -1909,6 +1948,11 @@ private static String getKeyNameFromMethod(Method method) {
19091948
} else if (name.startsWith("is") && name.length() > 2) {
19101949
key = name.substring(2);
19111950
} else {
1951+
// Only check for record-style accessors if this is actually a record type
1952+
// This maintains backward compatibility - classes with lowercase methods won't be affected
1953+
if (isRecordType && isRecordStyleAccessor(name, method)) {
1954+
return name;
1955+
}
19121956
return null;
19131957
}
19141958
// if the first letter in the key is not uppercase, then skip.
@@ -1925,6 +1969,37 @@ private static String getKeyNameFromMethod(Method method) {
19251969
return key;
19261970
}
19271971

1972+
/**
1973+
* Checks if a method is a record-style accessor.
1974+
* Record accessors have lowercase names without get/is prefixes and are not inherited from standard Java classes.
1975+
*
1976+
* @param methodName the name of the method
1977+
* @param method the method to check
1978+
* @return true if this is a record-style accessor, false otherwise
1979+
*/
1980+
private static boolean isRecordStyleAccessor(String methodName, Method method) {
1981+
if (methodName.isEmpty() || !Character.isLowerCase(methodName.charAt(0))) {
1982+
return false;
1983+
}
1984+
1985+
// Exclude common bean/Object method names
1986+
if (EXCLUDED_RECORD_METHOD_NAMES.contains(methodName)) {
1987+
return false;
1988+
}
1989+
1990+
Class<?> declaringClass = method.getDeclaringClass();
1991+
if (declaringClass == null || declaringClass == Object.class) {
1992+
return false;
1993+
}
1994+
1995+
if (Enum.class.isAssignableFrom(declaringClass) || Number.class.isAssignableFrom(declaringClass)) {
1996+
return false;
1997+
}
1998+
1999+
String className = declaringClass.getName();
2000+
return !className.startsWith("java.") && !className.startsWith("javax.");
2001+
}
2002+
19282003
/**
19292004
* checks if the annotation is not null and the {@link JSONPropertyName#value()} is not null and is not empty.
19302005
* @param annotation the annotation to check
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package org.json.junit;
2+
3+
import static org.junit.Assert.assertEquals;
4+
import static org.junit.Assert.assertFalse;
5+
import static org.junit.Assert.assertTrue;
6+
7+
import java.io.StringReader;
8+
9+
import org.json.JSONObject;
10+
import org.json.junit.data.GenericBeanInt;
11+
import org.json.junit.data.MyEnum;
12+
import org.json.junit.data.MyNumber;
13+
import org.json.junit.data.PersonRecord;
14+
import org.junit.Ignore;
15+
import org.junit.Test;
16+
17+
/**
18+
* Tests for JSONObject support of Java record types.
19+
*
20+
* NOTE: These tests are currently ignored because PersonRecord is not an actual Java record.
21+
* The implementation now correctly detects actual Java records using reflection (Class.isRecord()).
22+
* These tests will need to be enabled and run with Java 17+ where PersonRecord can be converted
23+
* to an actual record type.
24+
*
25+
* This ensures backward compatibility - regular classes with lowercase method names will not
26+
* be treated as records unless they are actual Java record types.
27+
*/
28+
public class JSONObjectRecordTest {
29+
30+
/**
31+
* Tests that JSONObject can be created from a record-style class.
32+
* Record-style classes use accessor methods like name() instead of getName().
33+
*
34+
* NOTE: Ignored until PersonRecord is converted to an actual Java record (requires Java 17+)
35+
*/
36+
@Test
37+
@Ignore("Requires actual Java record type - PersonRecord needs to be a real record (Java 17+)")
38+
public void jsonObjectByRecord() {
39+
PersonRecord person = new PersonRecord("John Doe", 30, true);
40+
JSONObject jsonObject = new JSONObject(person);
41+
42+
assertEquals("Expected 3 keys in the JSONObject", 3, jsonObject.length());
43+
assertEquals("John Doe", jsonObject.get("name"));
44+
assertEquals(30, jsonObject.get("age"));
45+
assertEquals(true, jsonObject.get("active"));
46+
}
47+
48+
/**
49+
* Test that Object methods (toString, hashCode, equals, etc.) are not included
50+
*
51+
* NOTE: Ignored until PersonRecord is converted to an actual Java record (requires Java 17+)
52+
*/
53+
@Test
54+
@Ignore("Requires actual Java record type - PersonRecord needs to be a real record (Java 17+)")
55+
public void recordStyleClassShouldNotIncludeObjectMethods() {
56+
PersonRecord person = new PersonRecord("Jane Doe", 25, false);
57+
JSONObject jsonObject = new JSONObject(person);
58+
59+
// Should NOT include Object methods
60+
assertFalse("Should not include toString", jsonObject.has("toString"));
61+
assertFalse("Should not include hashCode", jsonObject.has("hashCode"));
62+
assertFalse("Should not include equals", jsonObject.has("equals"));
63+
assertFalse("Should not include clone", jsonObject.has("clone"));
64+
assertFalse("Should not include wait", jsonObject.has("wait"));
65+
assertFalse("Should not include notify", jsonObject.has("notify"));
66+
assertFalse("Should not include notifyAll", jsonObject.has("notifyAll"));
67+
68+
// Should only have the 3 record fields
69+
assertEquals("Should only have 3 fields", 3, jsonObject.length());
70+
}
71+
72+
/**
73+
* Test that enum methods are not included when processing an enum
74+
*/
75+
@Test
76+
public void enumsShouldNotIncludeEnumMethods() {
77+
MyEnum myEnum = MyEnum.VAL1;
78+
JSONObject jsonObject = new JSONObject(myEnum);
79+
80+
// Should NOT include enum-specific methods like name(), ordinal(), values(), valueOf()
81+
assertFalse("Should not include name method", jsonObject.has("name"));
82+
assertFalse("Should not include ordinal method", jsonObject.has("ordinal"));
83+
assertFalse("Should not include declaringClass", jsonObject.has("declaringClass"));
84+
85+
// Enums should still work with traditional getters if they have any
86+
// But should not pick up the built-in enum methods
87+
}
88+
89+
/**
90+
* Test that Number subclass methods are not included
91+
*/
92+
@Test
93+
public void numberSubclassesShouldNotIncludeNumberMethods() {
94+
MyNumber myNumber = new MyNumber();
95+
JSONObject jsonObject = new JSONObject(myNumber);
96+
97+
// Should NOT include Number methods like intValue(), longValue(), etc.
98+
assertFalse("Should not include intValue", jsonObject.has("intValue"));
99+
assertFalse("Should not include longValue", jsonObject.has("longValue"));
100+
assertFalse("Should not include doubleValue", jsonObject.has("doubleValue"));
101+
assertFalse("Should not include floatValue", jsonObject.has("floatValue"));
102+
103+
// Should include the actual getter
104+
assertTrue("Should include number", jsonObject.has("number"));
105+
assertEquals("Should have 1 field", 1, jsonObject.length());
106+
}
107+
108+
/**
109+
* Test that generic bean with get() and is() methods works correctly
110+
*/
111+
@Test
112+
public void genericBeanWithGetAndIsMethodsShouldNotBeIncluded() {
113+
GenericBeanInt bean = new GenericBeanInt(42);
114+
JSONObject jsonObject = new JSONObject(bean);
115+
116+
// Should NOT include standalone get() or is() methods
117+
assertFalse("Should not include standalone 'get' method", jsonObject.has("get"));
118+
assertFalse("Should not include standalone 'is' method", jsonObject.has("is"));
119+
120+
// Should include the actual getters
121+
assertTrue("Should include genericValue field", jsonObject.has("genericValue"));
122+
assertTrue("Should include a field", jsonObject.has("a"));
123+
}
124+
125+
/**
126+
* Test that java.* classes don't have their methods picked up
127+
*/
128+
@Test
129+
public void javaLibraryClassesShouldNotIncludeTheirMethods() {
130+
StringReader reader = new StringReader("test");
131+
JSONObject jsonObject = new JSONObject(reader);
132+
133+
// Should NOT include java.io.Reader methods like read(), reset(), etc.
134+
assertFalse("Should not include read method", jsonObject.has("read"));
135+
assertFalse("Should not include reset method", jsonObject.has("reset"));
136+
assertFalse("Should not include ready method", jsonObject.has("ready"));
137+
assertFalse("Should not include skip method", jsonObject.has("skip"));
138+
139+
// Reader should produce empty JSONObject (no valid properties)
140+
assertEquals("Reader should produce empty JSON", 0, jsonObject.length());
141+
}
142+
143+
/**
144+
* Test mixed case - object with both traditional getters and record-style accessors
145+
*
146+
* NOTE: Ignored until PersonRecord is converted to an actual Java record (requires Java 17+)
147+
*/
148+
@Test
149+
@Ignore("Requires actual Java record type - PersonRecord needs to be a real record (Java 17+)")
150+
public void mixedGettersAndRecordStyleAccessors() {
151+
// PersonRecord has record-style accessors: name(), age(), active()
152+
// These should all be included
153+
PersonRecord person = new PersonRecord("Mixed Test", 40, true);
154+
JSONObject jsonObject = new JSONObject(person);
155+
156+
assertEquals("Should have all 3 record-style fields", 3, jsonObject.length());
157+
assertTrue("Should include name", jsonObject.has("name"));
158+
assertTrue("Should include age", jsonObject.has("age"));
159+
assertTrue("Should include active", jsonObject.has("active"));
160+
}
161+
162+
/**
163+
* Test that methods starting with uppercase are not included (not valid record accessors)
164+
*
165+
* NOTE: Ignored until PersonRecord is converted to an actual Java record (requires Java 17+)
166+
*/
167+
@Test
168+
@Ignore("Requires actual Java record type - PersonRecord needs to be a real record (Java 17+)")
169+
public void methodsStartingWithUppercaseShouldNotBeIncluded() {
170+
PersonRecord person = new PersonRecord("Test", 50, false);
171+
JSONObject jsonObject = new JSONObject(person);
172+
173+
// Record-style accessors must start with lowercase
174+
// Methods like Name(), Age() (uppercase) should not be picked up
175+
// Our PersonRecord only has lowercase accessors, which is correct
176+
177+
assertEquals("Should only have lowercase accessors", 3, jsonObject.length());
178+
}
179+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package org.json.junit.data;
2+
3+
/**
4+
* A test class that mimics Java record accessor patterns.
5+
* Records use accessor methods without get/is prefixes (e.g., name() instead of getName()).
6+
* This class simulates that behavior to test JSONObject's handling of such methods.
7+
*/
8+
public class PersonRecord {
9+
private final String name;
10+
private final int age;
11+
private final boolean active;
12+
13+
public PersonRecord(String name, int age, boolean active) {
14+
this.name = name;
15+
this.age = age;
16+
this.active = active;
17+
}
18+
19+
// Record-style accessors (no "get" or "is" prefix)
20+
public String name() {
21+
return name;
22+
}
23+
24+
public int age() {
25+
return age;
26+
}
27+
28+
public boolean active() {
29+
return active;
30+
}
31+
}

0 commit comments

Comments
 (0)