Search code examples
javaspringenumsjdbctemplateconverters

Spring JdbcTemplate fail to convert String to Enum (that extend specific interface), since default converter is called even if removed


My basic Spring boot app has an endpoint that accepts one single String and use it as key to read a char(1) value from DB, with NamedParameterJdbcTemplate. Simplified version of my code:

Application.java

@SpringBootApplication
@EnableCaching
@EnableScheduling
@ComponentScan(basePackages = {"my.package"})
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Controller.java

@RestController
@RequestMapping(path = "enpoint", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
public MyController {

    @Autowired
    private NamedParameterJdbcTemplate namedJdbcTemplate;

    @PostMapping("do")
    public Response doStuff(@RequestBody Request request) {
        return this.namedJdbcTemplate.queryForObject(
            "SELECT ONE_CHAR as value FROM TABLE WHERE KEY = :KEY",
            new MapSqlParameterSource("KEY", request.getKey()),
            new BeanPropertyRowMapper(Response.class)
        );
    }
}

Request.java

public class Request {
    private String key;
    // ... constructor / getter / setters ... 
}

Response.java

Since in the db table the column ONE_CHAR can have only 3 possible values (A, B or C). Instead of having a Response object with just "private String value;", I created an MyEnum that has only said chars.

public class Response {
    private MyEnum value;
    // ... constructor / getter / setters ... 
}

MyEnum.java

'A', 'B' and 'C' are not good names for my enum values, so I put that information inside an 'id' variable in my enum.

public enum MyEnum implements EnumById {

    GOOD_NAME("A"), NICE_NAME("B"), PERFECT_NAME("C");

    private final String id;
    // ... constructor / getter / setters ... 
}

EnumById.java

I created an interface 'EnumById' so I have a common way to handle all enums with the same issue (the values I want to represent as enums are something that can not or should not be used as variable name).

public interface EnumById {
    String getId();
}

This code of course does not works. When spring tries to convert whatever value is read from DB, the default StringToEnumConverter does the conversion by enum name and not by calling my 'getId()' method (as expected). I have to create my own converter... But since:

  • EnumById can be applied on multiple enums
  • I need the Class reference to call clazz.getEnumConstants()
  • I do not want to write a custom converter for each Enum that will extend EnumById

... I need to create a custom ConverterFactory

StringToEnumByIdConverterFactory.java

public class StringToEnumByIdConverterFactory implements ConverterFactory<String, EnumById> {

    private static class StringToEnumByIdConverter<T extends EnumById> implements Converter<String, T> {

        private final T[] values;

        public StringToEnumByIdConverter(final T[] values) {
                this.values = values;
        }

        public T convert(final String source) {
            for (final T value : values) {
                if (source.equals(value.getId())) {
                    return value;
                }
            }
            return null;
        }
    }

    public <T extends EnumById> Converter<String, T> getConverter(final Class<T> targetType) {
        return new StringToEnumByIdConverter<>(targetType.getEnumConstants());
    }
}

WebConfig.java

As https://www.baeldung.com/spring-type-conversions tells us I must add my converter factory to the spring format register... so I wrote:

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverterFactory(new StringToEnumByIdConverterFactory());
    }
}

Which.... is wrong... My converter will never be called since the default String-to-Enum converter has priority (since it was added earlier that my converter to the formatter registry).

My solution is to remove the default converter (org.springframework.core.convert.support.StringToEnumConverterFactory), put mine and add again the default one, so my WebConfig class is now like this:

WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.removeConvertible(String.class, Enum.class);
        registry.addConverterFactory(new StringToEnumByIdConverterFactory());
        registry.addConverterFactory(new StringToEnumConverterFactory());
    }
}

Which is wrong again because for whatever reason StringToEnumConverterFactory is not public (not even the ConversionUtils class used inside it, why? StringToEnumConverterFactory is even declared final) ... So I had to copy that code in my project.


Now that at least my application run, I debugged it and verified that the registry is properly updated when addFormatters is called.

When I call the endpoint the default converter is still called. I get the same exception I would get if no converter related code was ever written/used/called:

org.springframework.beans.TypeMismatchException: Failed to convert property value of type 'java.lang.String' to required type 'my.package.constants.Constants$MyEnum' for property 'value'; nested exception is org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] to type [my.package.constants.Constants$MyEnum] for value 'A'; nested exception is java.lang.IllegalArgumentException: No enum constant 'my.package.constants.Constants.MyEnum.A'

By looking at the stack trace I found that org.springframework.beans.TypeConverterDelegate.convertIfNecessary is the problem.

I went to debug and found out that the ConversionService used does not have the same converters I saw when I was debugging my WebConfig class (the register in my class had ~155 converters, the ConversionService used here has only 52). I can clearly see the default StringToEnumConverterFactory:IDE Screenshot

Where/How I cant tell the correct ConversionService (there are more than one?) to use my converters?


UPDATE: FOUND THE PROBLEM, BUT STILL UNRESOLVED

I use new BeanPropertyRowMapper(Response.class) which has its own ConversionService: private ConversionService conversionService = DefaultConversionService.getSharedInstance();

This bypass any Spring magic and directly gets a DefaultConversionService... Why the hell is the norm? To properly add my Enum conversion I would have to create my own class that extends BeanPropertyRowMapper... this seems extremely convoluted for now reason. Am I missing something?


UPDATE: FOUND A "FAKE" SOLUTION (SINCE IS UGLY AS HELL)

Wrote this before the query, and the conversion works now... I moved it somewhere it will be called only one time and still works.

// this is what BeanPropertyRowMapper use
DefaultConversionService sharedInstance = (DefaultConversionService) DefaultConversionService.getSharedInstance();
// remove default String to Enum conversion
sharedInstance.removeConvertible(String.class, Enum.class); 
// addd my converter factory
sharedInstance.addConverterFactory(new StringToEnumByCodeConverterFactory());
// add again all default converter back, since:
//  - I removed only the String->Enum one
//  - Converters are placed in a LinkedMap
// No duplicates are inserted, I only get that the default Enum converter is added AFTER my converter
DefaultConversionService.addDefaultConverters(sharedInstance);
// Now my converter has priority on the default Strin->Enum one and BeanPropertyRowMapper can use it

This is dumb and ugly... is this really the only way to trick BeanPropertyRowMapper to use my converter without having to create a whole new class?


Solution

  • Answer added in the first post. Basically it was loading a different ConversionService instance and this is by design. Wrote a workaround, the proper "elegant" solution would be to write a custom row mapper.