spring-boot icon indicating copy to clipboard operation
spring-boot copied to clipboard

Enhancement proposal: give jdbcclient access to conversion services for converting custom object types from db.

Open alexanderankin opened this issue 5 months ago • 2 comments

Enhancement Description: allow JdbcClient to convert custom object types in records. for example, in postgres you may have a jsonb and you'd want to be able to convert that into something so that you can parse it onto your record class while fetching:

record CustomRecord(JsonNode jsonbColumn) {}
jdbcClient.sql("select jsonb_column from some_table").query(CustomRecord.class).list();

code references:

  • currently only NamedParameterJdbcTemplate is passed to JdbcClient:
    • https://github.com/spring-projects/spring-boot/blob/162c929a80605f6f1d776757527dbbb6db3fb944/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/jdbc/JdbcClientAutoConfiguration.java#L41-L44
  • jdbcClient instantiates its own SimplePropertyRowMapper's (https://github.com/spring-projects/spring-framework/blob/d2ea5b444812b4c55e00fecb7b6451073677061d/spring-jdbc/src/main/java/org/springframework/jdbc/core/simple/DefaultJdbcClient.java#L199-L206)
  • SimplePropertyRowMapper is supposed to be able to take a conversion service, but currently requires quite a bit of boilerplate to leverage via Spring Boot - https://github.com/spring-projects/spring-framework/blob/d2ea5b444812b4c55e00fecb7b6451073677061d/spring-jdbc/src/main/java/org/springframework/jdbc/core/SimplePropertyRowMapper.java#L92-L120

possible solution:

  • refactor DefaultJdbcClient constructors
from their current form
class DefaultJdbcClient {

    private final JdbcOperations classicOps;

    private final NamedParameterJdbcOperations namedParamOps;

    private final Map<Class<?>, RowMapper<?>> rowMapperCache = new ConcurrentHashMap<>();


    public DefaultJdbcClient(DataSource dataSource) {
        this.classicOps = new JdbcTemplate(dataSource);
        this.namedParamOps = new NamedParameterJdbcTemplate(this.classicOps);
    }

    public DefaultJdbcClient(JdbcOperations jdbcTemplate) {
        Assert.notNull(jdbcTemplate, "JdbcTemplate must not be null");
        this.classicOps = jdbcTemplate;
        this.namedParamOps = new NamedParameterJdbcTemplate(jdbcTemplate);
    }

    public DefaultJdbcClient(NamedParameterJdbcOperations jdbcTemplate) {
        Assert.notNull(jdbcTemplate, "JdbcTemplate must not be null");
        this.classicOps = jdbcTemplate.getJdbcOperations();
        this.namedParamOps = jdbcTemplate;
    }
}
to this pattern:
class DefaultJdbcClient {
    private final JdbcOperations classicOps;
    private final NamedParameterJdbcOperations namedParamOps;
    private final ConversionService conversionService;
    private final Map<Class<?>, RowMapper<?>> rowMapperCache = new ConcurrentHashMap<>();

    public DefaultJdbcClient(DataSource dataSource) {
        this(new JdbcTemplate(dataSource));
    }

    public DefaultJdbcClient(JdbcOperations jdbcTemplate) {
        this(new NamedParameterJdbcTemplate(jdbcTemplate));
    }

    public DefaultJdbcClient(NamedParameterJdbcOperations jdbcTemplate) {
        this(jdbcTemplate.getJdbcOperations(), jdbcTemplate, DefaultConversionService.getSharedInstance());
    }

    public DefaultJdbcClient(
            JdbcOperations jdbcOperations,
            NamedParameterJdbcOperations namedParameterJdbcOperations,
            ConversionService conversionService
    ) {
        Assert.notNull(jdbcOperations, "jdbcOperations must not be null");
        Assert.notNull(namedParameterJdbcOperations, "namedParameterJdbcOperations must not be null");
        Assert.notNull(conversionService, "conversionService must not be null");
        this.classicOps = jdbcOperations;
        this.namedParamOps = namedParameterJdbcOperations;
        this.conversionService = conversionService;
    }
}

  • add a JdbcClient.create method that is just going to be kept in sync with the bottom constructor (just allow user to pass all components)
  • pass this conversion service where appropriate to simple property row mapper constructors et al
  • change the AutoConfiguration class from
	@Bean
	JdbcClient jdbcClient(NamedParameterJdbcTemplate jdbcTemplate) {
		return JdbcClient.create(jdbcTemplate);
	}

to

	@Bean
	JdbcClient jdbcClient(NamedParameterJdbcTemplate jdbcTemplate, /*kosher?*/ @Nullable ConversionService conversionService) {
		return JdbcClient.create(jdbcTemplate, java.util.Objects.requireNonNullElseGet(conversionService, DefaultConversionService::getSharedInstance));
	}

benefits

you can now register converters as beans and they will be able to be used for jdbcClient queries.

considerations

  • not sure how ok it is to rely on the conversion service bean as I understand its intent was for converting http requests/responses, hence why i made it an optional dependency and defaulted it to: java.util.Objects.requireNonNullElseGet(conversionService, DefaultConversionService::getSharedInstance).
  • alternative to consider is just to write boilerplate, eg. by creating your own row mappers - https://stackoverflow.com/a/78655612

alexanderankin avatar Aug 25 '24 14:08 alexanderankin