diff --git a/release-notes/VERSION-2.x b/release-notes/VERSION-2.x index e65fabf2f4..a48dd549e4 100644 --- a/release-notes/VERSION-2.x +++ b/release-notes/VERSION-2.x @@ -18,6 +18,8 @@ Project: jackson-databind serialization #5151: Add new exception type, `MissingInjectValueException`, to be used for failed `@JacksonInject` +#5152: Support "iPhone" style capitalized properties (add + `MapperFeature.FIX_FIELD_NAME_CASE_MISMATCH`) #5179: Add "current token" info into `MismatchedInputException` #5192: Record types are broken on Android when using R8 (reported by @HelloOO7) diff --git a/src/main/java/com/fasterxml/jackson/databind/MapperFeature.java b/src/main/java/com/fasterxml/jackson/databind/MapperFeature.java index 3a053193c8..9040f7abe7 100644 --- a/src/main/java/com/fasterxml/jackson/databind/MapperFeature.java +++ b/src/main/java/com/fasterxml/jackson/databind/MapperFeature.java @@ -218,6 +218,17 @@ public enum MapperFeature implements ConfigFeature */ INFER_CREATOR_FROM_CONSTRUCTOR_PROPERTIES(true), + /** + * Feature that when enabled will allow getters with is-Prefix also for + * non-boolean return types; if disabled only methods that return + * {@code boolean} or {@code Boolean} qualify as "is getters". + *

+ * Feature is disabled by default for backwards compatibility. + * + * @since 2.14 + */ + ALLOW_IS_GETTERS_FOR_NON_BOOLEAN(false), + /** * Feature that determines whether nominal property type of {@link Void} is * allowed for Getter methods to indicate {@code null} valued pseudo-property @@ -541,15 +552,20 @@ public enum MapperFeature implements ConfigFeature ALLOW_EXPLICIT_PROPERTY_RENAMING(false), /** - * Feature that when enabled will allow getters with is-Prefix also for - * non-boolean return types; if disabled only methods that return - * {@code boolean} or {@code Boolean} qualify as "is getters". + * Feature that can be enabled to solve problem where an upper-case letter in + * the first 2 characters of Java field name (like {@code "IPhone"} or {@code "iPhone"}) + * prevents match with property name derived from accessors (setter like + * {@code getIPhone()} becomes {@code "iphone"}). + * If enabled, additional checking is done with case-insensitive comparison (for + * cases of the first or second letter of Field name being upper-case) to merge + * accessors. If disabled, no special processing is done. *

- * Feature is disabled by default for backwards compatibility. + * Feature is disabled by default in 2.x for backwards-compatibility. + * It will be enabled by default in 3.0. * - * @since 2.14 + * @since 2.20 */ - ALLOW_IS_GETTERS_FOR_NON_BOOLEAN(false), + FIX_FIELD_NAME_CASE_MISMATCH(false), /* /****************************************************** diff --git a/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java b/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java index 8ad07a53a8..851227bc6e 100644 --- a/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java +++ b/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertiesCollector.java @@ -433,12 +433,11 @@ protected void collectAll() _potentialCreators = new PotentialCreators(); // First: gather basic accessors - LinkedHashMap props = new LinkedHashMap(); + LinkedHashMap props = new LinkedHashMap<>(); // 14-Nov-2024, tatu: Previously skipped checking fields for Records; with 2.18+ won't // (see [databind#3628], [databind#3895], [databind#3992], [databind#4626]) _addFields(props); // note: populates _fieldRenameMappings - _addMethods(props); // 25-Jan-2016, tatu: Avoid introspecting (constructor-)creators for non-static // inner classes, see [databind#1502] @@ -446,7 +445,11 @@ protected void collectAll() if (!_classDef.isNonStaticInnerClass()) { _addCreators(props); } - + // 11-Jun-2025, tatu: [databind#5152] May need to "fix" mis-matching leading case + // wrt Fields vs Accessors + if (_config.isEnabled(MapperFeature.FIX_FIELD_NAME_CASE_MISMATCH)) { + _fixLeadingFieldNameCase(props); + } // Remove ignored properties, first; this MUST precede annotation merging // since logic relies on knowing exactly which accessor has which annotation _removeUnwantedProperties(props); @@ -547,10 +550,9 @@ private Map _putAnyGettersInTheEnd( protected void _addFields(Map props) { final AnnotationIntrospector ai = _annotationIntrospector; - /* 28-Mar-2013, tatu: For deserialization we may also want to remove - * final fields, as often they won't make very good mutators... - * (although, maybe surprisingly, JVM _can_ force setting of such fields!) - */ + // 28-Mar-2013, tatu: For deserialization we may also want to remove + // final fields, as often they won't make very good mutators... + // (although, maybe surprisingly, JVM _can_ force setting of such fields!) final boolean pruneFinalFields = !_forSerialization && !_config.isEnabled(MapperFeature.ALLOW_FINAL_FIELDS_AS_MUTATORS); final boolean transientAsIgnoral = _config.isEnabled(MapperFeature.PROPAGATE_TRANSIENT_MARKER); @@ -1318,6 +1320,97 @@ private String _checkRenameByField(String implName) { return implName; } + /* + /********************************************************** + /* Internal methods; merging/fixing case-differences + /********************************************************** + */ + + // @since 2.20 + protected void _fixLeadingFieldNameCase(Map props) + { + // 11-Jun-2025, tatu: [databind#5152] May need to "fix" mis-matching leading case + // wrt Fields vs Accessors + + // First: find possible candidates where: + // + // 1. Property only has Field + // 2. Field does NOT have explicit name (renaming) + // 3. Implicit name has upper-case for first and/or second character + + Map fieldsToCheck = null; + for (Map.Entry entry : props.entrySet()) { + POJOPropertyBuilder prop = entry.getValue(); + + // First: (1) and (2) + if (!prop.hasFieldAndNothingElse() + || prop.isExplicitlyNamed()) { + continue; + } + // Second: (3) + if (!_firstOrSecondCharUpperCase(entry.getKey())) { + continue; + } + if (fieldsToCheck == null) { + fieldsToCheck = new HashMap<>(); + } + fieldsToCheck.put(entry.getKey(), prop); + } + /*// DEBUGGING + if (fieldsToCheck == null) { + System.err.println("_fixLeadingCase, candidates -> null; props -> "+props.keySet()); + } else { + System.err.println("_fixLeadingCase, candidates -> "+fieldsToCheck); + } + */ + + if (fieldsToCheck == null) { + return; + } + + for (Map.Entry fieldEntry : fieldsToCheck.entrySet()) { + Iterator> it = props.entrySet().iterator(); + final POJOPropertyBuilder fieldProp = fieldEntry.getValue(); + final String fieldName = fieldEntry.getKey(); + + while (it.hasNext()) { + Map.Entry propEntry = it.next(); + final POJOPropertyBuilder prop = propEntry.getValue(); + + // Skip anything that has Field (can't merge) + if (prop == fieldProp || prop.hasField()) { + continue; + } + if (fieldName.equalsIgnoreCase(propEntry.getKey())) { + // Remove non-Field property; add its accessors to Field one + it.remove(); + fieldProp.addAll(prop); + // Should we continue with possible other accessors? + // For now assume only one merge needed/desired + break; + } + } + } + } + + // @since 2.20 + private boolean _firstOrSecondCharUpperCase(String name) { + switch (name.length()) { + case 0: + return false; + default: + if (!Character.isLowerCase(name.charAt(1))) { + return true; + } + // fall through + case 1: + if (!Character.isLowerCase(name.charAt(0))) { + return true; + } + return false; + } + } + /* /********************************************************** /* Internal methods; removing ignored properties @@ -1420,6 +1513,7 @@ protected void _renameProperties(Map props) // With renaming need to do in phases: first, find properties to rename Iterator> it = props.entrySet().iterator(); LinkedList renamed = null; + while (it.hasNext()) { Map.Entry entry = it.next(); POJOPropertyBuilder prop = entry.getValue(); diff --git a/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertyBuilder.java b/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertyBuilder.java index 088a7f8b30..4dca05c49c 100644 --- a/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertyBuilder.java +++ b/src/main/java/com/fasterxml/jackson/databind/introspect/POJOPropertyBuilder.java @@ -385,6 +385,12 @@ public Class getRawPrimaryType() { @Override public boolean hasField() { return _fields != null; } + // @since 2.20 additional accessor + public boolean hasFieldAndNothingElse() { + return (_fields != null) + && ((_getters == null) && (_setters == null) && (_ctorParameters == null)); + } + @Override public boolean hasConstructorParameter() { return _ctorParameters != null; } @@ -416,9 +422,8 @@ public AnnotatedMethod getGetter() } // But if multiple, verify that they do not conflict... for (; next != null; next = next.next) { - /* [JACKSON-255] Allow masking, i.e. do not report exception if one - * is in super-class from the other - */ + // Allow masking, i.e. do not report exception if one + // is in super-class from the other Class currClass = curr.value.getDeclaringClass(); Class nextClass = next.value.getDeclaringClass(); if (currClass != nextClass) { diff --git a/src/test/java/com/fasterxml/jackson/databind/tofix/IPhoneStyleProperty5152Test.java b/src/test/java/com/fasterxml/jackson/databind/misc/IPhoneStyleProperty5152Test.java similarity index 83% rename from src/test/java/com/fasterxml/jackson/databind/tofix/IPhoneStyleProperty5152Test.java rename to src/test/java/com/fasterxml/jackson/databind/misc/IPhoneStyleProperty5152Test.java index 5ca116728c..7e1125fba8 100644 --- a/src/test/java/com/fasterxml/jackson/databind/tofix/IPhoneStyleProperty5152Test.java +++ b/src/test/java/com/fasterxml/jackson/databind/misc/IPhoneStyleProperty5152Test.java @@ -1,11 +1,9 @@ -package com.fasterxml.jackson.databind.tofix; +package com.fasterxml.jackson.databind.misc; import org.junit.jupiter.api.Test; -import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.*; import com.fasterxml.jackson.databind.testutil.DatabindTestUtil; -import com.fasterxml.jackson.databind.testutil.failure.JacksonTestFailureExpected; import static org.junit.jupiter.api.Assertions.*; @@ -73,10 +71,27 @@ public void setPhone(String value) { } } + // [databind#2696] + static class OAuthTokenBean { + protected String oAuthToken; + + public OAuthTokenBean(String t) { + oAuthToken = t; + } + + public String getOAuthToken() { + return this.oAuthToken; + } + + public void setOAuthToken(String oAuthToken) { + this.oAuthToken = oAuthToken; + } + } + private final ObjectMapper MAPPER = jsonMapperBuilder() + .enable(MapperFeature.FIX_FIELD_NAME_CASE_MISMATCH) .build(); - @JacksonTestFailureExpected @Test public void testIPhoneStyleProperty() throws Exception { // Test with iPhone style property @@ -104,21 +119,19 @@ public void testRegularPojoProperty() throws Exception { } // [databind#2835]: "dLogHeader" property - @JacksonTestFailureExpected @Test public void testDLogHeaderStyleProperty() throws Exception { // Test with DLogHeader style property - String json = "{\"dLogHeader\":\"Debug Log Header\"}"; + String json = "{\"DLogHeader\":\"Debug Log Header\"}"; DLogHeaderBean result = MAPPER.readValue(json, DLogHeaderBean.class); assertNotNull(result); assertEquals("Debug Log Header", result.getDLogHeader()); // Test serialization String serialized = MAPPER.writeValueAsString(result); - assertEquals("{\"dLogHeader\":\"Debug Log Header\"}", serialized); + assertEquals("{\"DLogHeader\":\"Debug Log Header\"}", serialized); } - @JacksonTestFailureExpected @Test public void testKBSBroadCastingStyleProperty() throws Exception { // Test with KBSBroadCasting style property @@ -132,7 +145,6 @@ public void testKBSBroadCastingStyleProperty() throws Exception { assertEquals("{\"KBSBroadCasting\":\"Korean Broadcasting System\"}", serialized); } - @JacksonTestFailureExpected @Test public void testPhoneStyleProperty() throws Exception { // Test with Phone style property @@ -146,4 +158,10 @@ public void testPhoneStyleProperty() throws Exception { assertEquals("{\"Phone\":\"iPhone 15\"}", serialized); } -} \ No newline at end of file + // [databind#2696] + @Test + public void testOAuthProperty() throws Exception { + assertEquals(a2q("{'oAuthToken':'123'}"), + MAPPER.writeValueAsString(new OAuthTokenBean("123"))); + } +}