Skip to content

Commit 387fabf

Browse files
authored
Fix #5152: support "iPhone" style properties -- add MapperFeature.FIX_FIELD_NAME_UPPER_CASE_PREFIX (#5187)
1 parent 70deb56 commit 387fabf

File tree

5 files changed

+161
-26
lines changed

5 files changed

+161
-26
lines changed

release-notes/VERSION-2.x

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Project: jackson-databind
1818
serialization
1919
#5151: Add new exception type, `MissingInjectValueException`, to be used
2020
for failed `@JacksonInject`
21+
#5152: Support "iPhone" style capitalized properties (add
22+
`MapperFeature.FIX_FIELD_NAME_CASE_MISMATCH`)
2123
#5179: Add "current token" info into `MismatchedInputException`
2224
#5192: Record types are broken on Android when using R8
2325
(reported by @HelloOO7)

src/main/java/com/fasterxml/jackson/databind/MapperFeature.java

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,17 @@ public enum MapperFeature implements ConfigFeature
218218
*/
219219
INFER_CREATOR_FROM_CONSTRUCTOR_PROPERTIES(true),
220220

221+
/**
222+
* Feature that when enabled will allow getters with is-Prefix also for
223+
* non-boolean return types; if disabled only methods that return
224+
* {@code boolean} or {@code Boolean} qualify as "is getters".
225+
* <p>
226+
* Feature is disabled by default for backwards compatibility.
227+
*
228+
* @since 2.14
229+
*/
230+
ALLOW_IS_GETTERS_FOR_NON_BOOLEAN(false),
231+
221232
/**
222233
* Feature that determines whether nominal property type of {@link Void} is
223234
* allowed for Getter methods to indicate {@code null} valued pseudo-property
@@ -541,15 +552,20 @@ public enum MapperFeature implements ConfigFeature
541552
ALLOW_EXPLICIT_PROPERTY_RENAMING(false),
542553

543554
/**
544-
* Feature that when enabled will allow getters with is-Prefix also for
545-
* non-boolean return types; if disabled only methods that return
546-
* {@code boolean} or {@code Boolean} qualify as "is getters".
555+
* Feature that can be enabled to solve problem where an upper-case letter in
556+
* the first 2 characters of Java field name (like {@code "IPhone"} or {@code "iPhone"})
557+
* prevents match with property name derived from accessors (getter like
558+
* {@code getIPhone()} becomes {@code "iphone"}).
559+
* If enabled, additional checking is done with case-insensitive comparison (for
560+
* cases of the first or second letter of Field name being upper-case) to merge
561+
* accessors. If disabled, no special processing is done.
547562
* <p>
548-
* Feature is disabled by default for backwards compatibility.
563+
* Feature is disabled by default in 2.x for backwards-compatibility.
564+
* It will be enabled by default in 3.0.
549565
*
550-
* @since 2.14
566+
* @since 2.20
551567
*/
552-
ALLOW_IS_GETTERS_FOR_NON_BOOLEAN(false),
568+
FIX_FIELD_NAME_UPPER_CASE_PREFIX(false),
553569

554570
/*
555571
/******************************************************

src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java

Lines changed: 101 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -433,20 +433,23 @@ protected void collectAll()
433433
_potentialCreators = new PotentialCreators();
434434

435435
// First: gather basic accessors
436-
LinkedHashMap<String, POJOPropertyBuilder> props = new LinkedHashMap<String, POJOPropertyBuilder>();
436+
LinkedHashMap<String, POJOPropertyBuilder> props = new LinkedHashMap<>();
437437

438438
// 14-Nov-2024, tatu: Previously skipped checking fields for Records; with 2.18+ won't
439439
// (see [databind#3628], [databind#3895], [databind#3992], [databind#4626])
440440
_addFields(props); // note: populates _fieldRenameMappings
441-
442441
_addMethods(props);
443442
// 25-Jan-2016, tatu: Avoid introspecting (constructor-)creators for non-static
444443
// inner classes, see [databind#1502]
445444
// 14-Nov-2024, tatu: Similarly need Creators for Records too (2.18+)
446445
if (!_classDef.isNonStaticInnerClass()) {
447446
_addCreators(props);
448447
}
449-
448+
// 11-Jun-2025, tatu: [databind#5152] May need to "fix" mis-matching leading case
449+
// wrt Fields vs Accessors
450+
if (_config.isEnabled(MapperFeature.FIX_FIELD_NAME_UPPER_CASE_PREFIX)) {
451+
_fixLeadingFieldNameCase(props);
452+
}
450453
// Remove ignored properties, first; this MUST precede annotation merging
451454
// since logic relies on knowing exactly which accessor has which annotation
452455
_removeUnwantedProperties(props);
@@ -547,10 +550,9 @@ private Map<String, POJOPropertyBuilder> _putAnyGettersInTheEnd(
547550
protected void _addFields(Map<String, POJOPropertyBuilder> props)
548551
{
549552
final AnnotationIntrospector ai = _annotationIntrospector;
550-
/* 28-Mar-2013, tatu: For deserialization we may also want to remove
551-
* final fields, as often they won't make very good mutators...
552-
* (although, maybe surprisingly, JVM _can_ force setting of such fields!)
553-
*/
553+
// 28-Mar-2013, tatu: For deserialization we may also want to remove
554+
// final fields, as often they won't make very good mutators...
555+
// (although, maybe surprisingly, JVM _can_ force setting of such fields!)
554556
final boolean pruneFinalFields = !_forSerialization && !_config.isEnabled(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS);
555557
final boolean transientAsIgnoral = _config.isEnabled(MapperFeature.PROPAGATE_TRANSIENT_MARKER);
556558

@@ -1318,6 +1320,97 @@ private String _checkRenameByField(String implName) {
13181320
return implName;
13191321
}
13201322

1323+
/*
1324+
/**********************************************************
1325+
/* Internal methods; merging/fixing case-differences
1326+
/**********************************************************
1327+
*/
1328+
1329+
// @since 2.20
1330+
protected void _fixLeadingFieldNameCase(Map<String, POJOPropertyBuilder> props)
1331+
{
1332+
// 11-Jun-2025, tatu: [databind#5152] May need to "fix" mis-matching leading case
1333+
// wrt Fields vs Accessors
1334+
1335+
// First: find possible candidates where:
1336+
//
1337+
// 1. Property only has Field
1338+
// 2. Field does NOT have explicit name (renaming)
1339+
// 3. Implicit name has upper-case for first and/or second character
1340+
1341+
Map<String, POJOPropertyBuilder> fieldsToCheck = null;
1342+
for (Map.Entry<String, POJOPropertyBuilder> entry : props.entrySet()) {
1343+
POJOPropertyBuilder prop = entry.getValue();
1344+
1345+
// First: (1) and (2)
1346+
if (!prop.hasFieldAndNothingElse()
1347+
|| prop.isExplicitlyNamed()) {
1348+
continue;
1349+
}
1350+
// Second: (3)
1351+
if (!_firstOrSecondCharUpperCase(entry.getKey())) {
1352+
continue;
1353+
}
1354+
if (fieldsToCheck == null) {
1355+
fieldsToCheck = new HashMap<>();
1356+
}
1357+
fieldsToCheck.put(entry.getKey(), prop);
1358+
}
1359+
/*// DEBUGGING
1360+
if (fieldsToCheck == null) {
1361+
System.err.println("_fixLeadingCase, candidates -> null; props -> "+props.keySet());
1362+
} else {
1363+
System.err.println("_fixLeadingCase, candidates -> "+fieldsToCheck);
1364+
}
1365+
*/
1366+
1367+
if (fieldsToCheck == null) {
1368+
return;
1369+
}
1370+
1371+
for (Map.Entry<String, POJOPropertyBuilder> fieldEntry : fieldsToCheck.entrySet()) {
1372+
Iterator<Map.Entry<String, POJOPropertyBuilder>> it = props.entrySet().iterator();
1373+
final POJOPropertyBuilder fieldProp = fieldEntry.getValue();
1374+
final String fieldName = fieldEntry.getKey();
1375+
1376+
while (it.hasNext()) {
1377+
Map.Entry<String, POJOPropertyBuilder> propEntry = it.next();
1378+
final POJOPropertyBuilder prop = propEntry.getValue();
1379+
1380+
// Skip anything that has Field (can't merge)
1381+
if (prop == fieldProp || prop.hasField()) {
1382+
continue;
1383+
}
1384+
if (fieldName.equalsIgnoreCase(propEntry.getKey())) {
1385+
// Remove non-Field property; add its accessors to Field one
1386+
it.remove();
1387+
fieldProp.addAll(prop);
1388+
// Should we continue with possible other accessors?
1389+
// For now assume only one merge needed/desired
1390+
break;
1391+
}
1392+
}
1393+
}
1394+
}
1395+
1396+
// @since 2.20
1397+
private boolean _firstOrSecondCharUpperCase(String name) {
1398+
switch (name.length()) {
1399+
case 0:
1400+
return false;
1401+
default:
1402+
if (!Character.isLowerCase(name.charAt(1))) {
1403+
return true;
1404+
}
1405+
// fall through
1406+
case 1:
1407+
if (!Character.isLowerCase(name.charAt(0))) {
1408+
return true;
1409+
}
1410+
return false;
1411+
}
1412+
}
1413+
13211414
/*
13221415
/**********************************************************
13231416
/* Internal methods; removing ignored properties
@@ -1420,6 +1513,7 @@ protected void _renameProperties(Map<String, POJOPropertyBuilder> props)
14201513
// With renaming need to do in phases: first, find properties to rename
14211514
Iterator<Map.Entry<String,POJOPropertyBuilder>> it = props.entrySet().iterator();
14221515
LinkedList<POJOPropertyBuilder> renamed = null;
1516+
14231517
while (it.hasNext()) {
14241518
Map.Entry<String, POJOPropertyBuilder> entry = it.next();
14251519
POJOPropertyBuilder prop = entry.getValue();

src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertyBuilder.java

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,12 @@ public Class<?> getRawPrimaryType() {
385385
@Override
386386
public boolean hasField() { return _fields != null; }
387387

388+
// @since 2.20 additional accessor
389+
public boolean hasFieldAndNothingElse() {
390+
return (_fields != null)
391+
&& ((_getters == null) && (_setters == null) && (_ctorParameters == null));
392+
}
393+
388394
@Override
389395
public boolean hasConstructorParameter() { return _ctorParameters != null; }
390396

@@ -416,9 +422,8 @@ public AnnotatedMethod getGetter()
416422
}
417423
// But if multiple, verify that they do not conflict...
418424
for (; next != null; next = next.next) {
419-
/* [JACKSON-255] Allow masking, i.e. do not report exception if one
420-
* is in super-class from the other
421-
*/
425+
// Allow masking, i.e. do not report exception if one
426+
// is in super-class from the other
422427
Class<?> currClass = curr.value.getDeclaringClass();
423428
Class<?> nextClass = next.value.getDeclaringClass();
424429
if (currClass != nextClass) {

src/test/java/com/fasterxml/jackson/databind/tofix/IPhoneStyleProperty5152Test.java renamed to src/test/java/com/fasterxml/jackson/databind/misc/IPhoneStyleProperty5152Test.java

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
package com.fasterxml.jackson.databind.tofix;
1+
package com.fasterxml.jackson.databind.misc;
22

33
import org.junit.jupiter.api.Test;
44

5-
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
65
import com.fasterxml.jackson.databind.*;
76
import com.fasterxml.jackson.databind.testutil.DatabindTestUtil;
8-
import com.fasterxml.jackson.databind.testutil.failure.JacksonTestFailureExpected;
97

108
import static org.junit.jupiter.api.Assertions.*;
119

@@ -73,10 +71,27 @@ public void setPhone(String value) {
7371
}
7472
}
7573

74+
// [databind#2696]
75+
static class OAuthTokenBean {
76+
protected String oAuthToken;
77+
78+
public OAuthTokenBean(String t) {
79+
oAuthToken = t;
80+
}
81+
82+
public String getOAuthToken() {
83+
return this.oAuthToken;
84+
}
85+
86+
public void setOAuthToken(String oAuthToken) {
87+
this.oAuthToken = oAuthToken;
88+
}
89+
}
90+
7691
private final ObjectMapper MAPPER = jsonMapperBuilder()
92+
.enable(MapperFeature.FIX_FIELD_NAME_UPPER_CASE_PREFIX)
7793
.build();
7894

79-
@JacksonTestFailureExpected
8095
@Test
8196
public void testIPhoneStyleProperty() throws Exception {
8297
// Test with iPhone style property
@@ -104,21 +119,19 @@ public void testRegularPojoProperty() throws Exception {
104119
}
105120

106121
// [databind#2835]: "dLogHeader" property
107-
@JacksonTestFailureExpected
108122
@Test
109123
public void testDLogHeaderStyleProperty() throws Exception {
110124
// Test with DLogHeader style property
111-
String json = "{\"dLogHeader\":\"Debug Log Header\"}";
125+
String json = "{\"DLogHeader\":\"Debug Log Header\"}";
112126
DLogHeaderBean result = MAPPER.readValue(json, DLogHeaderBean.class);
113127
assertNotNull(result);
114128
assertEquals("Debug Log Header", result.getDLogHeader());
115129

116130
// Test serialization
117131
String serialized = MAPPER.writeValueAsString(result);
118-
assertEquals("{\"dLogHeader\":\"Debug Log Header\"}", serialized);
132+
assertEquals("{\"DLogHeader\":\"Debug Log Header\"}", serialized);
119133
}
120134

121-
@JacksonTestFailureExpected
122135
@Test
123136
public void testKBSBroadCastingStyleProperty() throws Exception {
124137
// Test with KBSBroadCasting style property
@@ -132,7 +145,6 @@ public void testKBSBroadCastingStyleProperty() throws Exception {
132145
assertEquals("{\"KBSBroadCasting\":\"Korean Broadcasting System\"}", serialized);
133146
}
134147

135-
@JacksonTestFailureExpected
136148
@Test
137149
public void testPhoneStyleProperty() throws Exception {
138150
// Test with Phone style property
@@ -146,4 +158,10 @@ public void testPhoneStyleProperty() throws Exception {
146158
assertEquals("{\"Phone\":\"iPhone 15\"}", serialized);
147159
}
148160

149-
}
161+
// [databind#2696]
162+
@Test
163+
public void testOAuthProperty() throws Exception {
164+
assertEquals(a2q("{'oAuthToken':'123'}"),
165+
MAPPER.writeValueAsString(new OAuthTokenBean("123")));
166+
}
167+
}

0 commit comments

Comments
 (0)