jackson-databind
jackson-databind copied to clipboard
DefaultAccessorNamingStrategy converts to lowercases the whole first group of uppercase characters
Search before asking
- [X] I searched in the issues and found nothing similar.
Describe the bug
Hello, I've noticed that DefaultAccessorNamingStrategy will convert to lowercase more than one letter that follows the prefix of a getter. This is happening in the for-loop here: DefaultAccessorNamingStrategy::legacyManglePropertyName.
Based on the code and comments, this seems to be the intended default behavior, but it can result in unintuitive outcomes.
This was my use case:
@Data
class MyObject {
String iPhone; // <-- lombok generates the getter: getIPhone() {...}
}
If we use the default configuration, the resulting json can be a bit surprising:
{
"iphone": "test"
}
Are there use cases where we can benefit from lowercasing more than one character?
Version Information
latest master version
Reproduction
static class MyObj {
String iPhone;
// constructor
public String getIPhone() {
return iPhone;
}
}
@Test
void lowercasingMoreThanOneChar() throws Exception {
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(new MyObj("test"));
assertEquals("{\"iPhone\":\"test\"}", json); // <- fails, the key will be lowercase: "iphone"
}
Expected behavior
The expected outcome was to lowercase only the first character that follows the 'get' prefix.
Additional context
No response
The reason is abbreviations, e.g. HTMLParser -> htmlParser. That this doesn't work well with lombok is a known issue.
hello @yawkat, thanks for the quick response! I see, but even for this example it will still not work exactly as intended, right?
HTMLParser -> getHTMLParser() -> "htmlparser" (instead of "htmlParser")
Hmmmm, very tricky topic here. Do u have any suggestions wrt what can be done here? @etrandafir93
Maybe USE_STD_BEAN_NAMING is enough to fix your particular case.
@yawkat, @JooHyukKim,
Unfortunately USE_STD_BEAN_NAMING also fails, producing the key with with both characters as uppercase:
field: iPhone -> getter: getIPhone() -> json field: "IPhone".
Tested here:
@Test
void lowercasingMoreThanOneChar() throws Exception {
ObjectMapper mapper = new ObjectMapper().configure(USE_STD_BEAN_NAMING, true);
String json = mapper.writeValueAsString(new MyObj("test"));
assertEquals("{\"iPhone\":\"test\"}", json); // <-- fails, actual: {"IPhone":"test"}
}
To summarize, switching between Lombok data/value classes (or just class with IDE-generated getters) to Java records results in different json for these cases.
There are a few workarounds to avoid this at a field level - for eg: serialize using the fields instead of getters, custom @JsonProperty for this specific field, or even setting lombok.accessors.fluent=true for a record-like accessor) - same goes for deserialization.
Maybe a new naming strategy or some config property can address this at a project level - but you'll know better if a new config property will make sense here.
Maybe a new naming strategy or some config property can address this at a project level - but you'll know better if a new config property will make sense here
New naming strategy might be possible. But I doubt changing the default will push through --this will create disaster for so many projects out there.
Based on the code and comments, this seems to be the intended default behavior, but it can result in unintuitive outcomes.
With such expected side effect in mind, intuition may not enough to drive changing default behavior. If you need to resolve your case, maybe there is a customization point for accessor naming strategy that I am not aware of @etrandafir93 ?
@JooHyukKim - I never suggested changing the default configuration :)
Just wanted to highlight this situation and ask if it is/will be possible to achieve this via a dedicated configuration at the project level.
As I recall, the fundamental problem in case like this is that the name should be based on field name, and not on extracting it from getter or setter: latter is ambiguous as there's no 1-to-1 mapping. This might also require at least partially name-insensitive matching, to connect the two.
I don't think naming strategy is, unfortunately, quite enough to solve the problem.
@JooHyukKim - I never suggested changing the default configuration :)
Right, I guess I thought that's where discussion was heading towards :))
~~Just to be clear: by "latest master version", do you mean the unreleased version 3.0 (https://github.com/FasterXML/jackson-databind/tree/master)?~~
EDIT: Nope, likely means 2.17.2. Also:
To summarize, switching between Lombok data/value classes (or just class with IDE-generated getters) to Java records results in different json for these cases.
...means field named iPhone will result in JSON Object field name "iphone" for Lombok data/value class with , but "iPhone" when using Records (for e.g. record MyObj(String iPhone) { ... })
(I've just understood what that statement meant, just sharing in case it helps someone else.)
NOTE: What happens internally is that POJOPropertiesCollector found:
iPhonePOJOPropertyBuilderfor the package-private fieldiphonePOJOPropertyBuilderfor the public getter
...then removing the 1st property because it is "not visible" (default settings only considers public field as visible), leaving us with the 2nd property.
Just found out that JavaBean's API considers getIPhone() to belong to a property named IPhone:
...showing us how Records is not a 1-to-1 "drop-in" replacement for Bean classes.
Records do not follow Bean convention anyway, due to no set-/get-prefixes. And no need to modify capitalization.
The expected outcome was to lowercase only the first character that follows the 'get' prefix.
Out of curiosity, I tried to hack something out but these things don't look like public APIs...:
objectMapper.setAccessorNaming(new DefaultAccessorNamingStrategy.Provider() {
@Override
public AccessorNamingStrategy forPOJO(MapperConfig<?> config, AnnotatedClass targetClass) {
return new DefaultAccessorNamingStrategy(config, targetClass, _setterPrefix, _getterPrefix, _isGetterPrefix, _baseNameValidator) {
@Override
public String findNameForRegularGetter(AnnotatedMethod am, String methodName) {
if (_getterPrefix != null && methodName.startsWith(_getterPrefix)) {
String name = methodName.substring(_getterPrefix.length());
char firstChar = name.charAt(0);
char secondChar = name.charAt(1);
boolean annoyingName = Character.isUpperCase(firstChar) && Character.isUpperCase(secondChar);
if (annoyingName) {
int firstLowerCaseIndex = 0;
char[] chars = name.toCharArray();
for (int i = 0; i < chars.length; i++) {
if (Character.isLowerCase(chars[i])) {
firstLowerCaseIndex = i;
break;
}
}
// To convert e.g.:
// - `IPhone` --> `iPhone`
// - `HTMLParser` --> `htmlParser`
int lastFrontUpperCaseIndex = firstLowerCaseIndex - 1;
return name.substring(0, lastFrontUpperCaseIndex).toLowerCase(Locale.ROOT)
+ name.substring(lastFrontUpperCaseIndex);
}
return super.findNameForRegularGetter(am, methodName);
}
return null;
}
};
}
});
Is this really the "official" way to provide an alternative AccessorNamingStrategy, or it's actually at another place, or there's none (e.g. AccessorNamingStrategy is an internal API)?
'setAccessorNaming()' method seems quite public (behavior wise) to me. It provides 'Annotated' and value as inputs. 🤔 May I ask what attributes made u think it isnt public?
Also There might be some JavaDoc written about customizing idk
May I ask what attributes made u think it isnt public?
While having protected access modifier normally means "subclasses are welcome to use me", seeing a field name starting with underscore always gives me "I AM AN INTERNAL API" vibes, especially since I'm not familiar with naming convention used in this project.
So, AccessorNamingStrategy is a public extension point, but only meant for application developers (sort of end user), not for modules and so on. And not by jackson-databind itself (it shouldn't need that).
So it is not an internal extension point.
Hey @cowtowncoder and everyone else, thank you all for stepping in here!
To clarify, I mentioned Lombok and records just to provide context for how we encountered the issue. The question is whether it’s ok to lowercase the entire first group of uppercase characters following the get/set prefix, without offering an alternative strategy via configuration.
From what I can see, there are cases where this default behavior might make sense, particularly when dealing with single words in all uppercase:
getID() -> "id"
getHTML() => "html"
However, this will make it impossible to serialize correctly -via getters- fields starting with a single lowercase character, such as: iPhone, iPad, eGaming, eCommerce... etc.
So, I think the core question of this issue is: does it make sense to have a naming strategy that only lowercase the first letter following the get prefix? If not, and we should just use field serialization for these cases, I think we can go ahead and close this issue. :)
I don't think that changing default behavior (either with or without USE_STD_BEAN_NAMING) is something we can do for 2.x.
I thought USE_STD_BEAN_NAMING would lower-case all leading upper-case; and non-USE_STD_BEAN_NAMING just the first one, fwtw.
But ultimately to fix the issue would, I think, require new option: anchoring property name to Field name, and matching possibly case-mismatching setter/getter to that name. That is something we could consider for 3.0, even as default -- or possibly, as 2.19 feature, defaulting to false in 2.x, but true in 3.0.
Previously:
- https://github.com/FasterXML/jackson-databind/issues/4197
- https://github.com/FasterXML/jackson-databind/issues/3710
- https://github.com/FasterXML/jackson-databind/issues/3538
- https://github.com/FasterXML/jackson-databind/issues/2868
- https://github.com/FasterXML/jackson-databind/issues/2696
- https://github.com/FasterXML/jackson-databind/issues/2327
- https://github.com/FasterXML/jackson-databind/issues/2267
- https://github.com/FasterXML/jackson-databind/issues/1701
- https://github.com/FasterXML/jackson-databind/issues/1455
- https://github.com/FasterXML/jackson-databind/issues/886
Oh. And looking at MapperFeature.USE_STD_BEAN_NAMING; case of "getURL()"
- When enabled ->
"URL"(and hence) - When disabled ->
"url"
so it won't do what would work here, "Url". But even if there was such a setting, I don't think it would cover all cases.
Not 100% sure what would be expected here: custom AccessorNamingStrategy can be used to make things work as expected (to some degree at least); but I don't see how to change DefaultAccessorNamingStrategy to do this generally and in backwards-compatible way.
Closing; may be re-filed with specific ask.