diff --git a/src/main/java/com/fasterxml/jackson/databind/introspect/DefaultAccessorNamingStrategy.java b/src/main/java/com/fasterxml/jackson/databind/introspect/DefaultAccessorNamingStrategy.java index 30c57e2ea9..057f0db08c 100644 --- a/src/main/java/com/fasterxml/jackson/databind/introspect/DefaultAccessorNamingStrategy.java +++ b/src/main/java/com/fasterxml/jackson/databind/introspect/DefaultAccessorNamingStrategy.java @@ -19,9 +19,25 @@ public class DefaultAccessorNamingStrategy extends AccessorNamingStrategy { + /** + * Definition of a handler API to use for checking whether given base name + * (remainder of accessor method name after removing prefix) is acceptable + * based on various rules. + * + * @since 2.12 + */ + public interface BaseNameValidator { + public boolean accept(char firstChar, String basename, int offset); + } + protected final MapperConfig> _config; protected final AnnotatedClass _forClass; + /** + * Optional validator for checking that base name + */ + protected final BaseNameValidator _baseNameValidator; + protected final boolean _stdBeanNaming; protected final String _getterPrefix; @@ -34,7 +50,8 @@ public class DefaultAccessorNamingStrategy protected final String _mutatorPrefix; protected DefaultAccessorNamingStrategy(MapperConfig> config, AnnotatedClass forClass, - String mutatorPrefix, String getterPrefix, String isGetterPrefix) + String mutatorPrefix, String getterPrefix, String isGetterPrefix, + BaseNameValidator baseNameValidator) { _config = config; _forClass = forClass; @@ -43,6 +60,7 @@ protected DefaultAccessorNamingStrategy(MapperConfig> config, AnnotatedClass f _mutatorPrefix = mutatorPrefix; _getterPrefix = getterPrefix; _isGetterPrefix = isGetterPrefix; + _baseNameValidator = baseNameValidator; } @Override @@ -116,14 +134,22 @@ public String modifyFieldName(AnnotatedField field, String name) { * @param basename Name of accessor/mutator method, not including prefix * ("get"/"is"/"set") */ - protected static String legacyManglePropertyName(final String basename, final int offset) + protected String legacyManglePropertyName(final String basename, final int offset) { final int end = basename.length(); if (end == offset) { // empty name, nope return null; } - // next check: is the first character upper case? If not, return as is char c = basename.charAt(offset); + // 12-Oct-2020, tatu: Additional configurability; allow checking that + // base name is acceptable (currently just by checking first character) + if (_baseNameValidator != null) { + if (!_baseNameValidator.accept(c, basename, offset)) { + return null; + } + } + + // next check: is the first character upper case? If not, return as is char d = Character.toLowerCase(c); if (c == d) { @@ -145,7 +171,7 @@ protected static String legacyManglePropertyName(final String basename, final in return sb.toString(); } - protected static String stdManglePropertyName(final String basename, final int offset) + protected String stdManglePropertyName(final String basename, final int offset) { final int end = basename.length(); if (end == offset) { // empty name, nope @@ -153,6 +179,14 @@ protected static String stdManglePropertyName(final String basename, final int o } // first: if it doesn't start with capital, return as-is char c0 = basename.charAt(offset); + // 12-Oct-2020, tatu: Additional configurability; allow checking that + // base name is acceptable (currently just by checking first character) + if (_baseNameValidator != null) { + if (!_baseNameValidator.accept(c0, basename, offset)) { + return null; + } + } + char c1 = Character.toLowerCase(c0); if (c0 == c1) { return basename.substring(offset); @@ -230,8 +264,9 @@ protected boolean _isGroovyMetaClassGetter(AnnotatedMethod am) { *
- * + * and no additional restrictions on base names accepted (configurable for + * limits using {@link BaseNameValidator}), allowing names like + * "get_value()" and "getvalue()". */ public static class Provider extends AccessorNamingStrategy.Provider @@ -245,13 +280,11 @@ public static class Provider protected final String _getterPrefix; protected final String _isGetterPrefix; - protected final boolean _allowLowerCaseFirstChar; - protected final boolean _allowNonLetterFirstChar; + protected final BaseNameValidator _baseNameValidator; public Provider() { this("set", JsonPOJOBuilder.DEFAULT_WITH_PREFIX, - "get", "is", - true, true); + "get", "is", null); } protected Provider(Provider p, @@ -259,30 +292,26 @@ protected Provider(Provider p, String getterPrefix, String isGetterPrefix) { this(setterPrefix, withPrefix, getterPrefix, isGetterPrefix, - p._allowLowerCaseFirstChar, p._allowNonLetterFirstChar); + p._baseNameValidator); } - protected Provider(Provider p, - boolean allowLowerCaseFirstChar, boolean allowNonLetterFirstChar) + protected Provider(Provider p, BaseNameValidator vld) { this(p._setterPrefix, p._withPrefix, - p._getterPrefix, p._isGetterPrefix, - allowLowerCaseFirstChar, allowNonLetterFirstChar); + p._getterPrefix, p._isGetterPrefix, vld); } protected Provider(String setterPrefix, String withPrefix, String getterPrefix, String isGetterPrefix, - boolean allowLowerCaseFirstChar, boolean allowNonLetterFirstChar) + BaseNameValidator vld) { _setterPrefix = setterPrefix; _withPrefix = withPrefix; _getterPrefix = getterPrefix; _isGetterPrefix = isGetterPrefix; - _allowLowerCaseFirstChar = allowLowerCaseFirstChar; - _allowNonLetterFirstChar = allowNonLetterFirstChar; + _baseNameValidator = vld; } - - + /** * Mutant factory for changing the prefix used for "setter" * methods @@ -371,19 +400,34 @@ public Provider withIsGetterPrefix(String prefix) { * character (like {@code "_"} or number {@code 1}) are accepted as valid or not: * consider difference between "setter-methods" {@code setValue()} and {@code set_value()}. * - * @return Provider instance with specified is-getter-prefix + * @return Provider instance with specified validity rules */ public Provider withFirstCharAcceptance(boolean allowLowerCaseFirstChar, boolean allowNonLetterFirstChar) { - return new Provider(this, - allowLowerCaseFirstChar, allowNonLetterFirstChar); + return withBaseNameValidator( + FirstCharBasedValidator.forFirstNameRule(allowLowerCaseFirstChar, allowNonLetterFirstChar)); + } + + /** + * Mutant factory for specifying validator that is used to further verify that + * base name derived from accessor name is acceptable: this can be used to add + * further restrictions such as limit that the first character of the base name + * is an upper-case letter. + * + * @param vld Validator to use, if any; {@code null} to indicate no additional rules + * + * @return Provider instance with specified base name validator to use, if any + */ + public Provider withBaseNameValidator(BaseNameValidator vld) { + return new Provider(this, vld); } @Override public AccessorNamingStrategy forPOJO(MapperConfig> config, AnnotatedClass targetClass) { return new DefaultAccessorNamingStrategy(config, targetClass, - _setterPrefix, _getterPrefix, _isGetterPrefix); + _setterPrefix, _getterPrefix, _isGetterPrefix, + _baseNameValidator); } @Override @@ -394,7 +438,8 @@ public AccessorNamingStrategy forBuilder(MapperConfig> config, JsonPOJOBuilder.Value builderConfig = (ai == null) ? null : ai.findPOJOBuilderConfig(builderClass); String mutatorPrefix = (builderConfig == null) ? _withPrefix : builderConfig.withPrefix; return new DefaultAccessorNamingStrategy(config, builderClass, - mutatorPrefix, _getterPrefix, _isGetterPrefix); + mutatorPrefix, _getterPrefix, _isGetterPrefix, + _baseNameValidator); } @Override @@ -404,6 +449,60 @@ public AccessorNamingStrategy forRecord(MapperConfig> config, AnnotatedClass r } } + /** + * Simple implementation of {@link BaseNameValidator} that checks the + * first character and nothing else. + *
+ * Instances are to be constructed using method + * {@link FirstCharBasedValidator#forFirstNameRule}. + */ + public static class FirstCharBasedValidator + implements BaseNameValidator + { + private final boolean _allowLowerCaseFirstChar; + private final boolean _allowNonLetterFirstChar; + + protected FirstCharBasedValidator(boolean allowLowerCaseFirstChar, + boolean allowNonLetterFirstChar) { + _allowLowerCaseFirstChar = allowLowerCaseFirstChar; + _allowNonLetterFirstChar = allowNonLetterFirstChar; + } + + /** + * Factory method to use for getting an instance with specified first-character + * restrictions, if any; or {@code null} if no checking is needed. + * + * @param allowLowerCaseFirstChar Whether base names that start with lower-case + * letter (like {@code "a"} or {@code "b"}) are accepted as valid or not: + * consider difference between "setter-methods" {@code setValue()} and {@code setvalue()}. + * @param allowNonLetterFirstChar Whether base names that start with non-letter + * character (like {@code "_"} or number {@code 1}) are accepted as valid or not: + * consider difference between "setter-methods" {@code setValue()} and {@code set_value()}. + * + * @return Validator instance to use, if any; {@code null} to indicate no additional + * rules applied (case when both arguments are {@code false}) + */ + public static BaseNameValidator forFirstNameRule(boolean allowLowerCaseFirstChar, + boolean allowNonLetterFirstChar) { + if (!allowLowerCaseFirstChar && !allowNonLetterFirstChar) { + return null; + } + return new FirstCharBasedValidator(allowLowerCaseFirstChar, + allowNonLetterFirstChar); + } + + @Override + public boolean accept(char firstChar, String basename, int offset) { + // Ok, so... If UTF-16 letter, then check whether lc allowed + // (title-case and upper-case both assumed to be acceptable by default) + if (Character.isLetter(firstChar)) { + return _allowLowerCaseFirstChar || !Character.isLowerCase(firstChar); + } + // Otherwise, non-letter checking applied + return _allowNonLetterFirstChar; + } + } + /** * Implementation used for supporting "non-prefix" naming convention of * Java 14 {@code java.lang.Record} types, and in particular find default @@ -427,7 +526,7 @@ public RecordNaming(MapperConfig> config, AnnotatedClass forClass) { null, // trickier: regular fields are ok (handled differently), but should // we also allow getter discovery? For now let's do so - "get", "is"); + "get", "is", null); _fieldNames = new HashSet<>(); for (String name : JDK14Util.getRecordFieldNames(forClass.getRawType())) { _fieldNames.add(name); diff --git a/src/test/java/com/fasterxml/jackson/databind/introspect/AccessorNamingStrategyTest.java b/src/test/java/com/fasterxml/jackson/databind/introspect/AccessorNamingStrategyTest.java index 8f6de99c4c..82dd40aa7b 100644 --- a/src/test/java/com/fasterxml/jackson/databind/introspect/AccessorNamingStrategyTest.java +++ b/src/test/java/com/fasterxml/jackson/databind/introspect/AccessorNamingStrategyTest.java @@ -37,6 +37,19 @@ static class MixedBean2800_X { public int getY() { return 3; } } + // Bean for checking optional + @JsonPropertyOrder(alphabetic = true) + static class FirstLetterVariesBean { + // Do we allow lower-case letter as first letter following prefix? + public boolean island() { return true; } + + // Do we allow non-letter + public int get4Roses() { return 42; } + + // But good old upper-case letter is solid always... + public int getValue() { return 31337; } + } + static class AccNaming2800Underscore extends AccessorNamingStrategy { @Override @@ -177,4 +190,48 @@ public void testBaseAccessorCustomSetter() throws Exception SetterBean2800_Y result = mapper.readValue(a2q("{'y':42}"), SetterBean2800_Y.class); assertEquals(42, result.yyy); } + + /* + public boolean island() { return true; } + + // Do we allow non-letter + public int get_lost() { return 42; } + + // But good old upper-case letter is solid always... + public int getValue() { return 31337; } + */ + + public void testFirstLetterConfigs() throws Exception + { + final FirstLetterVariesBean input = new FirstLetterVariesBean(); + final String STD_EXP = a2q("{'4Roses':42,'land':true,'value':31337}"); + + // First: vanilla? About anything goes + ObjectMapper mapper = newJsonMapper(); + assertEquals(STD_EXP, mapper.writeValueAsString(input)); + + // also if explicitly configured as default: + mapper = JsonMapper.builder() + .accessorNaming(new DefaultAccessorNamingStrategy.Provider() + .withFirstCharAcceptance(true, true)) + .build(); + assertEquals(STD_EXP, mapper.writeValueAsString(input)); + + // But we can vary it + mapper = JsonMapper.builder() + .accessorNaming(new DefaultAccessorNamingStrategy.Provider() + // lower-case = ok; non-letter = not ok + .withFirstCharAcceptance(true, false)) + .build(); + assertEquals(a2q("{'land':true,'value':31337}"), + mapper.writeValueAsString(input)); + + mapper = JsonMapper.builder() + .accessorNaming(new DefaultAccessorNamingStrategy.Provider() + // lower-case = not ok; non-letter = ok + .withFirstCharAcceptance(false, true)) + .build(); + assertEquals(a2q("{'4Roses':42,'value':31337}"), + mapper.writeValueAsString(input)); + } }